Maya — The Divine Illusion (React Frontend)
Maya governs appearance: the UI is the illusion layer over the system. The illusion must never lie — every state the system can be in, the interface must be able to show.
Components
- Function components only. No classes except error boundaries (until
use+ error handling covers them). - One component per file; the file is named after the component (
ChatMessage.tsx). - A component over ~150 lines or with more than one reason to change gets split. Extract by responsibility, not by line count.
- Props are typed with an explicit
interface/type— never inline object literals in the signature, neverany. - Derive, don't sync: values computable from props or existing state are computed in render, not mirrored into
useState. Mirrored state is the root of most React bugs. - Children over configuration: prefer composition (
<Card><CardHeader/></Card>) to boolean-prop explosions (<Card withHeader headerText=…>).
Hooks
- Call order rules are absolute: no hooks in conditions, loops, or after early returns.
useEffectis for synchronizing with external systems (subscriptions, DOM, analytics) — not for data fetching, not for reacting to state you set yourself. MostuseEffects in app code are a smell.- Every effect that subscribes returns a cleanup that unsubscribes. Test by mounting twice (StrictMode does this for you — never disable it).
- Custom hooks (
useConversation,useAuth) are the unit of frontend logic reuse. If two components duplicate stateful logic, extract the hook.
State — the two-store rule
- Server state (anything persisted in Supabase or behind FastAPI) lives in TanStack Query — never copied into
useState/useReducer. Caching, refetching, and invalidation are Query's job. - UI state (open modals, input drafts, selected tab) lives in
useStatelocal to the closest owner. Lift only when two components genuinely share it. - Global client state (theme, current user) gets one small context or a zustand store — not Redux, not context-per-feature sprawl.
- Never store a Supabase session in your own state: read it from
supabase.authvia the official helper/hook and subscribe toonAuthStateChange.
Data fetching
- Two data paths, never blurred:
- App data → Supabase client directly from the frontend, guarded by RLS (see
yama). - Chat + AI operations → FastAPI on Render (see
vayu), authenticated with the Supabase access token inAuthorization: Bearer.
- App data → Supabase client directly from the frontend, guarded by RLS (see
- All reads go through TanStack Query with structured keys:
["conversations", userId],["messages", conversationId]. Mutations invalidate exactly the keys they change. - No
fetchinuseEffect. No unawaited promises in handlers — every mutation surfaces its error state. - Define one typed API client module for FastAPI calls (
src/lib/api.ts); components never callfetchdirectly, never build URLs inline. - Generate Supabase types (
supabase gen types typescript) and use the typed client. A schema change that breaks the frontend must break it at compile time.
Every async view has three states
- Loading, error, and empty are designed states, not afterthoughts: skeleton (not spinner) for loading, a retry affordance for errors, and an inviting empty state that says what to do first.
- Errors show what happened and what to do next, in user language — never a raw exception, never a bare "Something went wrong" without a retry.
- Wrap feature roots in an error boundary so one crashed widget doesn't blank the page.
Forms
react-hook-form+ azodschema per form. The schema is the single source of validation truth; share it with the API layer where shapes match.- Validate on blur and on submit; never only on submit for long forms.
- Disable the submit button while submitting; re-enable on settled. Double-submits are a frontend bug, not a backend problem.
Chat & streaming UI (the AI-native part)
- Render streamed tokens incrementally from the FastAPI SSE stream; never buffer a full completion before showing anything.
- Optimistic append: the user's message enters the list immediately with a
pendingmarker; reconcile with the server-assigned ID on ack, markfailedwith a retry action on error. - Preserve scroll intent: autoscroll only when the user is already at the bottom; a scroll-up pins the view and shows a "jump to latest" affordance.
- Abort in-flight generations with
AbortControllerwhen the user navigates away or hits stop — and tell the backend (seevayu), don't just drop the reader. - Render model output as sanitized markdown; never
dangerouslySetInnerHTMLraw model text. Model output is untrusted input (seemuruka).
Performance
- Measure before memoizing:
React.memo/useMemo/useCallbackonly on demonstrated re-render cost, not as decoration. - Long message lists are virtualized past ~100 items.
- Route-level code splitting via
lazy— the chat bundle should not pay for the settings page. - Images: explicit dimensions, lazy loading below the fold.
Environment & configuration
- Only
VITE_/NEXT_PUBLIC_-prefixed vars reach the browser, and everything so prefixed is public by definition — the Supabase anon key qualifies; the service-role key and any provider API key never do (seekubera). - All config read in one
src/lib/env.tsthat validates presence at startup — no scatteredimport.meta.envreads.
Before merging frontend work — checklist
- No server state in
useState; all fetches through TanStack Query with structured keys - Loading, error, and empty states designed for every async view
- Forms validated by a zod schema; submit protected against double-fire
- Streamed output renders incrementally; stop/abort works
- No secret reachable in browser code; env access centralized and validated
- Supabase types regenerated if the schema changed