EduFlow

Section 3 · Navigation and State

Shared State with Context and Reducers

19 min3 min readCode playground

Lesson at a glance

Share Trailhead’s hike list across tabs and screens with a reducer and context provided from the root layout, derive totals instead of storing them, and know when a state library earns its place.

  1. 1Where shared state lives in Expo Router
  2. 2A reducer for every change
  3. 3The provider and its hooks
  4. 4Derive, don’t duplicate
  5. 5When context isn’t enough

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Four parts of Trailhead now care about the same data. The Log tab lists hikes, the Stats tab totals them, the detail screen shows one, and the add-hike form (next lesson) creates them. If each screen loads and keeps its own copy, they drift apart: you delete a hike on the detail screen, go back, and the Stats tab still counts it, because stack screens and visited tabs stay mounted and don’t reload.

The fix is the React principle you already know, applied to a navigation tree: one source of truth, held above everything that needs it.

Where shared state lives in Expo Router

In a web app you’d put the provider near the root component. In Expo Router, the root component is src/app/_layout.tsx: it renders the Stack, which renders the tab group and every pushed screen. A provider placed around that Stack covers the whole app.

HikesProvider wraps the root Stack in src/app/_layout.tsx; the Log tab, the Stats tab and the hike detail screen all read the same hikes and dispatch actions to the same reducer. HikesProvider src/app/_layout.tsx root Stack Log tab Stats tab /hikes/[id] dispatch
One provider in the root layout feeds every screen

A reducer for every change

Hikes get added, edited, deleted and favorited. Rather than scattering setHikes calls across screens, describe each change as an action and handle them all in one pure function:

src/state/hikesReducer.ts
export type Hike = { id: string; name: string; date: string; distanceKm: number; favorite: boolean };

export type HikesAction =
  | { type: 'added'; hike: Hike }
  | { type: 'updated'; id: string; changes: Partial<Omit<Hike, 'id'>> }
  | { type: 'deleted'; id: string }
  | { type: 'favoriteToggled'; id: string };

export function hikesReducer(state: Hike[], action: HikesAction): Hike[] {
  switch (action.type) {
    case 'added':
      return [action.hike, ...state];
    case 'updated':
      return state.map((h) => (h.id === action.id ? { ...h, ...action.changes } : h));
    case 'deleted':
      return state.filter((h) => h.id !== action.id);
    case 'favoriteToggled':
      return state.map((h) => (h.id === action.id ? { ...h, favorite: !h.favorite } : h));
  }
}

Every case returns a new array, and a new object for the hike that changed. That’s how React knows something changed, and it’s what lets a memoized HikeRow skip re-rendering the 599 rows that didn’t.

Notice the action names: added, deleted, favoriteToggled. They describe what happened, not how to update state. That keeps screens simple (the detail screen reports that the user toggled a favorite and doesn’t care how the list is stored) and gives you one place to change when the rules change, for example when deleting a hike should also remove its photos.

Because the reducer has no React Native imports, it runs anywhere. You’ll test reducers like this one with plain Jest in section 5, and you’ll write one in this lesson’s exercise.

The provider and its hooks

src/state/hikes.tsx
import { createContext, use, useMemo, useReducer, type Dispatch, type ReactNode } from 'react';
import { hikesReducer, type Hike, type HikesAction } from './hikesReducer';

const HikesContext = createContext<Hike[] | null>(null);
const DispatchContext = createContext<Dispatch<HikesAction> | null>(null);

export function HikesProvider({ initial, children }: { initial: Hike[]; children: ReactNode }) {
  const [hikes, dispatch] = useReducer(hikesReducer, initial);
  return (
    <DispatchContext value={dispatch}>
      <HikesContext value={hikes}>{children}</HikesContext>
    </DispatchContext>
  );
}

export function useHikes() {
  const hikes = use(HikesContext);
  if (!hikes) throw new Error('useHikes must be used inside HikesProvider');
  return hikes;
}

export function useHike(id: string | undefined) {
  const hikes = useHikes();
  return useMemo(() => hikes.find((h) => h.id === id), [hikes, id]);
}

export function useHikesDispatch() {
  const dispatch = use(DispatchContext);
  if (!dispatch) throw new Error('useHikesDispatch must be used inside HikesProvider');
  return dispatch;
}

Then wrap the Stack in the root layout: <HikesProvider initial={sampleHikes}><Stack>…</Stack></HikesProvider>. In section 4 the initial data will come from the device’s database instead.

A few details worth copying. React 19 lets you render a context directly as its provider (<HikesContext value={…}>) and read it with use. Splitting state and dispatch into two contexts means components that only dispatch, like a delete button, don’t re-render when the list changes. And the hooks throw a clear error if someone forgets the provider, instead of failing later with “cannot read properties of null”.

A screen now reads like this:

TSX
const hike = useHike(id);
const dispatch = useHikesDispatch();
// …
<Pressable onPress={() => dispatch({ type: 'favoriteToggled', id: hike.id })}>

Derive, don’t duplicate

The Stats tab needs total distance, hike count and the longest hike. None of those are state. They’re derived from the hike list, so compute them:

TSX
const hikes = useHikes();
const stats = useMemo(() => {
  const totalKm = hikes.reduce((sum, h) => sum + h.distanceKm, 0);
  const longest = hikes.reduce((best, h) => (!best || h.distanceKm > best.distanceKm ? h : best), undefined as Hike | undefined);
  return { count: hikes.length, totalKm, longest };
}, [hikes]);

When context isn’t enough

Context re-renders every consumer when its value changes. For Trailhead’s hike list that’s fine. It becomes a problem when a value changes very often (a live GPS position updating every second) or when a large app has many unrelated consumers. Teams then reach for Zustand, a small store with selectors so components re-render only for the slice they read. Data that lives on a server has different problems again (caching, refetching, staleness), which is the job of TanStack Query; you’ll meet that in section 4. Start with reducer plus context, and move when you’ve measured a reason.

Next, the screen that creates hikes: a form that works with a phone keyboard.

Key takeaways

  • State used by several routes belongs above all of them, which in Expo Router means a provider in the root _layout.tsx.
  • A reducer keeps every change to the hike list in one pure, testable function that returns new arrays and objects.
  • Wrap context in small hooks such as useHikes() and useHike(id), so screens never touch the context object directly.
  • Derive totals and filtered lists from the source state with useMemo; don’t store them separately.
  • Reach for a library like Zustand when context re-renders become a measured problem, and for TanStack Query when the data lives on a server.

Further reading

Check your understanding

3 questions

Question 1 of 3

The Stats tab stores totalKm in its own useState, updated in an effect. After a user deletes a hike, the total is wrong until the app restarts. What’s the best fix?

Question 2 of 3

What’s wrong with this reducer case?

JavaScript
case 'favoriteToggled': {
  const hike = state.find((h) => h.id === action.id);
  hike.favorite = !hike.favorite;
  return state;
}
Question 3 of 3

Where should Trailhead’s HikesProvider be rendered so the Log tab, the Stats tab and the /hikes/[id] screen can all read it?

Practice

Code playground

Make the hikes reducer immutable

Trailhead’s hikesReducer mutates state, so screens don’t update. Rewrite it so every case returns new arrays and objects and never changes state. Keep the list sorted newest first by date (YYYY-MM-DD strings).

  • added: insert action.hike in date order. If a hike with that id already exists, return the same state.
  • updated: merge action.changes into the hike with action.id, re-sort, and never let changes overwrite the id.
  • deleted: remove the hike.
  • favoriteToggled: flip favorite; hikes that didn’t change keep their same object, so memoized rows skip re-rendering.
  • Any unknown action returns the same state (don’t throw).
// Trailhead keeps hikes sorted newest first (dates are 'YYYY-MM-DD').
function hikesReducer(state, action) {
  switch (action.type) {
    case 'added':
      state.push(action.hike);
      return state;
    case 'updated': {
      const hike = state.find((h) => h.id === action.id);
      Object.assign(hike, action.changes);
      return state;
    }
    case 'deleted':
      return state.filter((h) => h.id !== action.id);
    case 'favoriteToggled': {
      const hike = state.find((h) => h.id === action.id);
      hike.favorite = !hike.favorite;
      return state;
    }
    default:
      throw new Error('Unknown action ' + action.type);
  }
}

const start = [
  { id: 'a', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2, favorite: false },
  { id: 'b', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4, favorite: true },
];
console.log(hikesReducer(start, { type: 'added', hike: { id: 'c', name: 'Fern Gully', date: '2026-10-10', distanceKm: 3.6, favorite: false } }).map((h) => h.id));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • Added hikes land in date order in a new array
  • Adding an existing id returns the same state
  • Updates merge, re-sort, keep the id and don’t mutate
  • Toggling a favorite copies only the changed hike
  • Deleting returns a new, shorter array
  • Unknown actions return the same state without throwing