React Native – State: Context, Reducers and Custom Hooks

July 19, 20264 min readUpdated 8/24/2026

State in React Native is the React you already know. What changes is the environment it runs in: a phone can suspend your process at any moment, and a screen can be dropped from memory and rebuilt. Those two facts decide where state lives.

Four places, in order of preference

useState in the component for anything only that component cares about — which modal is open, what is typed into a field, whether an input is focused. Most state is this, and moving it upward is the most common way to make an app slower and harder to read.

A custom hook when one screen owns some state and the fetching around it. The demo app's profile screen loads addresses and saved cards through useProfileData rather than a provider — nothing else needs them, so putting them in context would keep them in memory for the whole session and re-render unrelated screens when they change.

Context for what genuinely spans the tree: who is signed in, what is in the cart, the menu, toasts.

A store — Redux, Zustand, Jotai — when context stops being enough. The demo app does not use one, and that is a real decision rather than an omission: its web sibling uses Redux for the admin section, and the mobile app has no admin section.

useReducer for rules

Reach for a reducer when updates depend on previous state and form a small closed set of operations. A cart is the canonical example:

    case 'ADD_ITEM': {
      const existing = state.items.find((item) => isSameConfiguration(item, action.payload));

      if (existing) {
        return {
          ...state,
          items: state.items.map((item) =>
            item.lineId === existing.lineId
              ? { ...item, quantity: item.quantity + action.payload.quantity }
              : item,
          ),
        };
      }

      return { ...state, items: [...state.items, action.payload] };
    }

Adding a pizza that is already in the cart bumps a quantity; adding a differently-topped one adds a line. That rule lives in one testable function instead of being spread across event handlers.

Note that every branch builds a new array with map or a spread. React compares by reference to decide whether to re-render, so mutating in place updates the data without updating the screen — the classic silent bug. There is no Immer here; the reducer is plain and the immutability is yours to keep.

Type the actions as a union

A discriminated union makes the reducer's switch exhaustively checked: TypeScript narrows action.payload from action.type, so adding a case and forgetting to handle it fails the build rather than being silently ignored.

Keep the reducer out of the provider

The reducer file imports nothing from React. That is what lets it be unit-tested in milliseconds — the demo app has eighteen tests over it, covering merge rules, removal, and the promise that it never mutates its input. The provider next door owns the effects; the reducer owns the rules.

Context, and the cost of it

A context value that changes re-renders every consumer. Two habits keep that bearable.

Split contexts by how often they change. Auth changes twice a session; the cart changes constantly. One combined context would re-render everything that only cares about the signed-in user every time somebody taps "+".

Memoise the value. An object literal in the provider's render is a new object every time, so every consumer re-renders on every render of the provider — even when nothing changed. Wrap it in useMemo, and wrap the functions you hand out in useCallback, or the memo achieves nothing because its dependencies are new each time.

The ordering problem

This one has no web equivalent in the docs and bites the first time you rehydrate anything:

  return (
    <SafeAreaProvider>
      <StripeProvider>
        <AuthProvider>
          <MenuProvider>
            <CartProvider>
              <ToastProvider>{children}</ToastProvider>
            </CartProvider>
          </MenuProvider>
        </AuthProvider>
      </StripeProvider>
    </SafeAreaProvider>
  );

⚠️ That nesting order is load-bearing. MenuProvider must sit above CartProvider, because restoring a saved cart calls useMenu() — a stored cart line holds only identifiers, so the prices come from the catalogue. Swap the two and the app crashes with "useMenu must be used inside a MenuProvider", but only on a launch where a saved cart exists. That is a bug that passes every test on a fresh install.

Composing the providers in one named component instead of nesting five of them in the root layout is what gives the constraint somewhere to be written down next to the code that depends on it.

A guard worth writing every time

Every consumer hook in the demo app checks for undefined and throws a sentence naming the missing provider. Without it, a component rendered outside its provider gets undefined and fails somewhere else entirely, with a message about a property of undefined.

React 19's use() replaces useContext() and reads the same value; unlike useContext it may be called conditionally. Not needed here, but it is the API going forward.

State that must survive the app dying

The mobile-specific part, and the reason the cart is not simply a context. The OS can suspend or kill your process without warning, so in-memory state is not durable. Anything the user would be upset to lose belongs on the device or on the server — lessons 15 and 16.

What is next

Talking to an API — starting with the fact that localhost means three different things.