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/*.ts use queryOptions + typed query keys
  • Mutations use useMutation with onSuccess invalidation
  • Loaders handle SSR prefetch; client uses queryClient.ensureQueryData to 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/) → use queryOptions + useQuery
  • Bare <form onSubmit> without formisch → use <Form> + <Field>
  • useEffect for data fetching → use TanStack Query instead
  • useState chains >5 fields → useReducer or Jotai atom
  • Math.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 />);