references/state.md
State, data, and hooks
Classify by lifetime
Almost every state management argument is really an unasked question: how long does this value need to live, and who is the source of truth?
| Lifetime | Tool | Example |
|---|---|---|
| One component | useState / useReducer | modal open, input focus |
| One screen and its children | props, or context scoped to the screen | selected filter |
| Owned by the server | TanStack Query | invoices, profile, feed |
| Owned by the client, app-wide | Zustand | session, theme, locale |
| Survives restart | MMKV, via Zustand persist | onboarding seen, theme |
| Secret and survives restart | expo-secure-store | refresh token |
| A form in flight | react-hook-form | anything with a submit button |
The rule that prevents most bugs: server data has exactly one home, and it is the query cache. Copying it into a store to "make it available elsewhere" means you now own invalidation, staleness, and the refetch story, and you will get one of them wrong.
Server state: TanStack Query
Query keys are a public API
One factory per feature, never inline string arrays. Inline keys drift, and a drifted key means an invalidation that silently does nothing.
// features/invoices/queryKeys.ts
export const invoiceKeys = {
all: ['invoices'] as const,
lists: () => [...invoiceKeys.all, 'list'] as const,
list: (filters: InvoiceFilters) => [...invoiceKeys.lists(), filters] as const,
details: () => [...invoiceKeys.all, 'detail'] as const,
detail: (id: string) => [...invoiceKeys.details(), id] as const,
};
The hierarchy is the point: invalidateQueries({ queryKey: invoiceKeys.lists() }) invalidates every list and no detail.
Wrap every query in a feature hook
Components never call useQuery directly. They call useInvoices(). That gives you one place to change the key, the select, the stale time, and the error mapping.
// features/invoices/api.ts
export function useInvoices(filters: InvoiceFilters) {
return useQuery({
queryKey: invoiceKeys.list(filters),
queryFn: () => fetchInvoices(filters),
staleTime: 30_000,
});
}
React Native specifics that are easy to miss
- Refetch on app foreground, not on window focus. The web default does not
fire on mobile. Wire focusManager to AppState.
- Wire
onlineManagerto NetInfo, or queries will not resume after the
device reconnects.
- Set a global
staleTime. The default of 0 means every remount refetches,
which on a tab navigator is a lot of requests. 30 to 60 seconds is a sane default; override per query.
- Persist the cache with the MMKV persister for offline-first screens, and
set buster to the app version so a schema change does not resurrect stale shapes.
- Optimistic updates need a rollback. If
onErrordoes not restore the
previous value from onMutate, do not do the optimistic update at all.
Errors
Normalise at the client boundary, not in components. lib/api/errors.ts turns whatever the backend sends into a small discriminated union (network | unauthorised | validation | server | unknown), and the UI switches on that. Otherwise every component grows its own error?.response?.data?.message chain, and each one is subtly different.
Client state: Zustand
One store per feature. A single global store becomes a god object with the same problems as the god hook.
// features/auth/store.ts
type AuthState = {
status: 'loading' | 'signedIn' | 'signedOut';
user: User | null;
signIn: (session: Session) => void;
signOut: () => void;
};
export const useAuthStore = create<AuthState>()((set) => ({
status: 'loading',
user: null,
signIn: (session) => set({ status: 'signedIn', user: session.user }),
signOut: () => set({ status: 'signedOut', user: null }),
}));
Rules:
- Always select.
useAuthStore((s) => s.user), neveruseAuthStore(). The
bare call subscribes the component to every field, and you will not notice the re-renders until the app is large.
- Actions live in the store, not in components that call
setfrom outside. - Keep derived values out. Compute them at the call site or with a selector.
Storing fullName alongside firstName guarantees they will disagree.
- Persist deliberately.
persistwith the MMKV storage adapter, an explicit
partialize listing what is saved, and a version plus migrate from the first release. Persisting the whole store is how you ship a crash-on-launch when a field changes type.
- Never persist tokens here. Those go to
expo-secure-store.
Context
Context is for dependency injection, not for state that changes often. Theme, i18n, a query client, a toast queue, a bottom sheet portal: yes. A value that updates on every keystroke: no, because every consumer re-renders.
If you reach for context to avoid passing two props through one level, pass the props. Prop drilling only becomes a real problem at three or more levels, and composition (children slots) usually solves it more cleanly than context.
Storage
| Need | Use |
|---|---|
| Fast synchronous key-value, state persistence, caches | react-native-mmkv |
| Tokens, refresh tokens, anything secret | expo-secure-store |
| Structured, queryable, large local data | SQLite (expo-sqlite), or a sync engine |
| Legacy compatibility only | AsyncStorage |
Separate MMKV instances per concern (auth, cache, prefs) so clearing one does not touch the others. Encrypt the instance that holds anything remotely sensitive. Keep every key in lib/storage/storage-keys.ts; key collisions are silent and produce the kind of bug that only reproduces on one tester's phone.
On sign-out: clear MMKV instances, clear SecureStore entries, and call queryClient.clear(). Missing the last one leaks the previous user's data into the next session, and it is the most commonly shipped version of this bug.
Forms
react-hook-form with a zod resolver. In React Native every input goes through Controller, because RN has no uncontrolled ref-based input the way the DOM does.
const schema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters'),
});
type FormValues = z.infer<typeof schema>;
const { control, handleSubmit, formState } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
Rules:
- The zod schema is the single source of truth for the shape, the types, and
the messages. Do not restate validation in the component.
- Never mirror form state into Zustand. Multi-step wizards keep one form
instance alive across steps, or lift the resolved values only on step submit.
- Disable submit on
formState.isSubmitting, and reset withreset()on
success rather than remounting the screen.
- Server validation errors go back into the form with
setError, mapped by
field name, so they render next to the offending input.
- The submit handler belongs to the screen or a feature hook, not to the
form organism. The organism takes onSubmit.
Hooks
Four kinds, four homes
| Kind | Knows about | Home |
|---|---|---|
| UI behaviour | nothing but React | src/hooks/ |
| Platform | RN and Expo APIs | src/hooks/ |
| Data | the API and query keys | features/<f>/api.ts |
| Feature logic | the domain | features/<f>/ |
Rules
- Name it for what it gives you, not how it works.
useInvoiceTotals, not
useInvoiceCalculationEffect. If naming is hard, the hook is doing two things.
- One concern each. A
useCheckoutthat fetches the cart, validates the
address, calls the payment SDK, and navigates is four hooks pretending to be one, and it is untestable as it stands.
- No lifecycle wrappers.
useMount,useDidUpdate,useUnmounthide the
dependency array, which is exactly the thing that needs to be visible. Use useEffect and let the linter check it.
- No
useprefix on a function that calls no hooks.useFormatCurrency
that just formats a number is a utility. Naming it as a hook makes the linter enforce rules it does not need and misleads every reader.
- Pass primitives, not fresh objects. A hook argument that is a new object
literal on every render will defeat every dependency array inside it.
- Return an object once there are more than two values. Tuples stop being
readable at three, and adding a value to a tuple is a breaking change at every call site.
- Do not use a store or a query inside an atom or molecule. This is the rule
that quietly decides whether your design system stays portable.
- Effects are a last resort. Most
useEffectcalls that set state are
either derived values (compute during render) or event responses (do it in the handler). An effect is correct when you are synchronising with something outside React: a native module, a subscription, a timer, the keyboard.
The custom hook that is actually a component
If a hook returns JSX, it is a component. If it returns JSX and state, it is a component with a render prop, and you should write it that way.