React state management policy
React state management policy
Server state: TanStack Query
All client→server data fetching uses @tanstack/react-query. Raw fetch() is banned in
apps/web/src/components/ and apps/web/src/routes/ except in apps/web/src/lib/api/
(the typed fetch wrappers themselves).
Pattern:
- Wrappers in
lib/api/*.tsusequeryOptions+ typed query keys - Mutations use
useMutationwithonSuccessinvalidation - Loaders handle SSR prefetch; client uses
queryClient.ensureQueryDatato reuse
TanStack Query code example
// lib/api/kits.ts
import { queryOptions } from "@tanstack/react-query";
import { apiClient } from "./client";
import type { Kit } from "~/types";
export const kitQueryOptions = (kitId: string) =>
queryOptions({
queryKey: ["kits", kitId],
queryFn: () => apiClient.get<Kit>(`/kits/${kitId}`),
staleTime: 5 * 60 * 1000,
});
// usage in component:
const { data: kit } = useQuery(kitQueryOptions(kitId));
Form state: formisch
All forms use @formisch/react with valibot schemas. No bare <form onSubmit> with
per-field useState.
Pattern: useForm, useField, setInput, getInput from @formisch/react.
Schema: valibot, typed end-to-end.
formisch code example
import { Form, useForm, useField, setInput } from "@formisch/react";
import { creatorFormSchema, type CreatorFormValues } from "~/lib/studio/creatorFormSchema";
// inside component:
const form = useForm<CreatorFormValues>({ schema: creatorFormSchema });
const nameField = useField(form, "name");
// in JSX:
<Form form={form} onSubmit={(values) => handleSubmit(values)}>
<input
value={nameField.value}
onChange={(e) => setInput(form, "name", e.target.value)}
/>
<button type="submit">Save</button>
</Form>
Client state: Jotai
Client-side reactive state (audio queue, UI state, preferences) uses Jotai atoms.
No new zustand stores.
Pattern: atom(value) for primitives, atom((get) => derived) for derived.
Use atomWithStorage for persisted atoms.
Jotai code example
// lib/audio/store.ts
import { atom } from "jotai";
export const playbackAtom = atom<"play" | "pause">("pause");
export const volumeAtom = atom(0.8);
// derived atom:
export const isPlayingAtom = atom((get) => get(playbackAtom) === "play");
// in component:
import { useAtomValue } from "jotai";
const playback = useAtomValue(playbackAtom);
const setPlayback = useSetAtom(playbackAtom);
Debugging: Jotai atoms show in React DevTools as atom values. Also see jotai-devtools
(if installed) for a dedicated panel.
Memoization: React Compiler
React Compiler is enabled in annotation mode. Add "use memo"; as the first line of
a file to opt it in. The compiler handles the rest — do not add new useMemo/useCallback
without measuring first.
React Compiler example
// "use memo"; ← first line of the file, opt-in for the whole file
import { useAtomValue } from "jotai";
export function AudioQueue({ trackIds }: { trackIds: string[] }) {
// compiler fuses these — no manual useMemo needed
const items = trackIds.map((id) => <TrackItem key={id} id={id} />);
return <ol>{items}</ol>;
}
Decision tree
"Does it come from the server?" → TanStack Query
└─ queryOptions + useQuery / useMutation
"Is it a form?" → formisch + valibot
└─ useForm / useField / setInput / getInput
"Is it complex multi-step state with events?" → consider @xstate/store (not default)
"Is it simple reactive state?" → Jotai atom
└─ atom(value) or atom((get) => derived)
"Is it memoization?" → React Compiler
└─ add "use memo"; as first line of file
Anti-patterns banned
fetch()in components/routes (except lib/api/) → usequeryOptions+useQuery- Bare
<form onSubmit>without formisch → use<Form>+<Field> useEffectfor data fetching → use TanStack Query insteaduseStatechains >5 fields → useReducer or Jotai atomMath.random()in render → hoist to a function
Agent guidance
How to read atom state
Jotai atoms show in React DevTools as atom values — inspect any component that calls
useAtomValue and the DevTools panel shows the current value. For a full atom graph
view, use jotai-devtools (installed separately; mount in StudioChrome.tsx under
process.env.NODE_ENV === "development").
How to debug TanStack Query
React Query DevTools are mounted in __root.tsx and visible in the bottom-right
corner during development. They show:
- Query cache: all active/pending/stale queries
- Per-query status:
fresh,stale,fetching,error - Manual cache manipulation:
queryClient.setQueryData(),queryClient.invalidateQueries()
How to seed state for tests
TanStack Query — pre-populate the cache before rendering:
queryClient.setQueryData(["kits", kitId], mockKit);
render(<KitDetail kitId={kitId} />);
Jotai — seed an atom before rendering:
import { seedAtom } from "jotai/utils";
seedAtom(volumeAtom, 0.5);
render(<Player />);