Steven's Knowledge

State Management

React Native state management — Redux Toolkit, Zustand, Jotai, TanStack Query selection and patterns

State Management

There is no single right answer for state management in React Native. The right answer comes from first separating what kind of state you have, then picking the simplest tool that handles it.

State Categories

CategoryExamplesRecommended Tool
Local UI stateA modal open/close, input draftuseState / useReducer
Cross-screen client stateSelected tab, cart, themezustand / Redux Toolkit / Jotai
Server stateUser profile from API, paginated listsTanStack Query / SWR / RTK Query
Form stateMulti-field forms with validationreact-hook-form
URL stateActive screen, paramsReact Navigation params
Persisted stateLogin token, preferencesMMKV + state-library middleware

Anti-pattern: dumping everything into one global store. A global store should hold what genuinely crosses screens. Local UI state in a global store is harder to reason about and harder to delete. Use the right scope.

Server State Is Not Like Client State

A common mistake is treating "data from the backend" the same as "the cart count". They behave differently:

ConcernClient stateServer state
Source of truthThe appThe server
Can go staleNoYes
Multiple components query itSometimesAlways
Needs cache invalidationNoYes
Needs retry / background refetchNoYes

Pick a server-state library (TanStack Query / SWR / RTK Query) for anything fetched from a backend. Then your client-state library only needs to handle the smaller, easier slice.

TanStack Query for Server State

// app/providers/Query.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: (failureCount, error) => {
        if (error instanceof HttpError && error.status < 500) return false;
        return failureCount < 3;
      },
    },
  },
});

export function QueryProvider({ children }: { children: ReactNode }) {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

// features/order/hooks/useOrders.ts
export function useOrders(status?: string) {
  return useQuery({
    queryKey: ['orders', { status }],
    queryFn: ({ signal }) => api.getOrders({ status }, signal),
  });
}

// features/order/hooks/useUpdateOrder.ts
export function useUpdateOrder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: api.updateOrder,
    onSuccess: () => qc.invalidateQueries({ queryKey: ['orders'] }),
  });
}

Offline + Background Refetch

// Refetch on app foreground
import { focusManager } from '@tanstack/react-query';
import { AppState } from 'react-native';

AppState.addEventListener('change', (status) => {
  focusManager.setFocused(status === 'active');
});

// Refetch when network returns
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

NetInfo.addEventListener((state) => {
  onlineManager.setOnline(!!state.isConnected);
});

Persist the Cache

import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();
const persister = createSyncStoragePersister({
  storage: {
    getItem: (k) => storage.getString(k) ?? null,
    setItem: (k, v) => storage.set(k, v),
    removeItem: (k) => storage.delete(k),
  },
});

persistQueryClient({ queryClient, persister, maxAge: 24 * 60 * 60 * 1000 });

Comparing Client-State Libraries

LibraryBoilerplateDevToolsTS ergonomicsBundleRecommended For
useState / ContextNoneNoneNative0Trivial cross-Widget sharing
ZustandMinimalFlipper pluginExcellent~1 KBDefault for most projects
JotaiAtom-per-pieceYesExcellent~3 KBApps where derived/atomic state dominates
Redux ToolkitModerateBest-in-classGood~12 KBLarge apps, strict patterns, time-travel debug
MobXDecorator-heavyYesOK~16 KBTeams from OO backgrounds
XStateHighYesExcellent~16 KBComplex flows expressed as state machines

Zustand: The Default

// features/cart/store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();
const mmkvStorage = {
  getItem: (k: string) => storage.getString(k) ?? null,
  setItem: (k: string, v: string) => storage.set(k, v),
  removeItem: (k: string) => storage.delete(k),
};

interface CartState {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
  total: () => number;
}

export const useCart = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      add: (item) => set((s) => ({ items: [...s.items, item] })),
      remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
      total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
    }),
    { name: 'cart', storage: createJSONStorage(() => mmkvStorage) },
  ),
);

// Component usage
const items = useCart((s) => s.items);     // resubscribe only when items change
const add = useCart((s) => s.add);          // function ref is stable

Slices Pattern

// Splits a large store into composed slices
type UserSlice = { user: User | null; signIn: (u: User) => void };
type CartSlice = { items: CartItem[]; add: (i: CartItem) => void };

const createUserSlice: StateCreator<UserSlice & CartSlice, [], [], UserSlice> = (set) => ({
  user: null,
  signIn: (user) => set({ user }),
});

const createCartSlice: StateCreator<UserSlice & CartSlice, [], [], CartSlice> = (set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
});

export const useStore = create<UserSlice & CartSlice>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}));

Redux Toolkit: For Stricter Architecture

Choose RTK when:

  • Team is large and you need enforced conventions.
  • You want time-travel debugging in production triage.
  • You're standardizing across multiple apps with a shared playbook.
// features/cart/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] as CartItem[] },
  reducers: {
    added: (state, action: PayloadAction<CartItem>) => {
      state.items.push(action.payload);
    },
    removed: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
  },
});

export const { added, removed } = cartSlice.actions;
export default cartSlice.reducer;

RTK Query Combines Both

If you're already on Redux, RTK Query gives you server-state caching too — fewer libraries to onboard.

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  tagTypes: ['Order'],
  endpoints: (build) => ({
    getOrders: build.query<Order[], void>({
      query: () => 'orders',
      providesTags: ['Order'],
    }),
    updateOrder: build.mutation<Order, Partial<Order> & { id: string }>({
      query: ({ id, ...patch }) => ({ url: `orders/${id}`, method: 'PATCH', body: patch }),
      invalidatesTags: ['Order'],
    }),
  }),
});

Jotai: Atomic State

import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubled] = useAtom(doubledAtom);
  return <Text onPress={() => setCount(count + 1)}>{count} / {doubled}</Text>;
}

Strong fit when state naturally factors into many small, derived pieces (Figma-style canvas tools, complex forms).

Forms: Don't Use Global State

// Bad: every keystroke updates the global store, triggering subscribers
const setDraft = useStore((s) => s.setDraft);
<TextInput onChangeText={setDraft} />

// Good: react-hook-form keeps form state local and uncontrolled
const { control, handleSubmit } = useForm<FormData>();

Patterns to Avoid

Prop Drilling Five Levels Deep

Lift state to the nearest common ancestor, then either pass props or extract a Context for that subtree.

One Massive Store

Split by domain (useUser, useCart, useNotifications). Independent updates, independent persistence, easier deletion.

Direct Mutation Without Immer

RTK ships with Immer; raw Redux requires immutable updates. Direct mutation is a frequent source of "state changes but UI doesn't update".

Async Work in Reducers

Async lives in thunks (RTK), mutations (TanStack Query), or store actions (Zustand). Reducers must be pure.

Decision Tree

Need it?
├── No → useState, you're done
└── Yes
    ├── From the server? → TanStack Query (default) / RTK Query (if on RTK)
    ├── Form? → react-hook-form
    ├── Single screen? → useState / useReducer
    └── Cross-screen?
        ├── Small team / project → Zustand
        ├── Derived & atomic state heavy → Jotai
        └── Large team / strict patterns → Redux Toolkit

On this page