>_devkit
react-native-expo
skills/react-native-expo/

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?

LifetimeToolExample
One componentuseState / useReducermodal open, input focus
One screen and its childrenprops, or context scoped to the screenselected filter
Owned by the serverTanStack Queryinvoices, profile, feed
Owned by the client, app-wideZustandsession, theme, locale
Survives restartMMKV, via Zustand persistonboarding seen, theme
Secret and survives restartexpo-secure-storerefresh token
A form in flightreact-hook-formanything 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 onlineManager to 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 onError does 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), never useAuthStore(). 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 set from 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. persist with 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

NeedUse
Fast synchronous key-value, state persistence, cachesreact-native-mmkv
Tokens, refresh tokens, anything secretexpo-secure-store
Structured, queryable, large local dataSQLite (expo-sqlite), or a sync engine
Legacy compatibility onlyAsyncStorage

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 with reset() 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

KindKnows aboutHome
UI behaviournothing but Reactsrc/hooks/
PlatformRN and Expo APIssrc/hooks/
Datathe API and query keysfeatures/<f>/api.ts
Feature logicthe domainfeatures/<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 useCheckout that 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, useUnmount hide the

dependency array, which is exactly the thing that needs to be visible. Use useEffect and let the linter check it.

  • No use prefix 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 useEffect calls 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.