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.
- 1Where shared state lives in Expo Router
- 2A reducer for every change
- 3The provider and its hooks
- 4Derive, don’t duplicate
- 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.
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:
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
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:
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:
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()anduseHike(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.