Command Palette

Search for a command to run...

Hectal
PHASE 27Advanced ~33 min· topic 3 of 6

Topic 27.3

State, Reducer and Persistence

In one line

Milestones 2 and 3: every change to the tasks goes through one reducer, the tasks and dispatch are shared by two contexts with custom hooks, and a small external store read with useSyncExternalStore saves them to localStorage so they survive a reload.

Think of it like this

A school register. Teachers don't scribble on each other's copies; every change ("Asha moved to class B") goes to the office, which writes it in one register. Anyone who needs the list reads the register, and at night it goes into a locked cupboard so it is still there tomorrow. The reducer is the office's rule book, the store is the register, context is how classrooms read it, and localStorage is the cupboard.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Reducer
A pure function that takes the current state and an action and returns the next state.
Action
A plain object describing what happened, with a type and the data needed.
dispatch
The function you call with an action to ask for a state change.
Context
A way to share a value with every component below a provider without passing props through each level.
Custom hook
A function whose name starts with use that wraps other hooks, like useTasks().
Persistence
Keeping data after the page closes or reloads.
localStorage
The browser's small key-value storage for strings that survives closing the tab.
External store
A plain JavaScript object outside React that holds state and tells subscribers when it changes.
useSyncExternalStore
The React 18 hook that reads an external store and re-renders when it changes.
Tearing
A bug where parts of the screen show different versions of the same data.

Step by step

01Milestone 2: write the reducer first

Write and test the reducer before any UI. It is the contract for every change. Throwing on an unknown action turns a typo ("move" instead of "moved") into a clear error instead of a silent no-op.

src/state/tasksReducer.jswhole filejavascript
export function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":   return [...tasks, { id: action.id, title: action.title, status: "todo", priority: "medium" }];
    case "edited":  return tasks.map((t) => (t.id === action.id ? { ...t, title: action.title } : t));
    case "moved":   return tasks.map((t) => (t.id === action.id ? { ...t, status: action.status } : t));
    case "deleted": return tasks.filter((t) => t.id !== action.id);
    default: throw new Error("Unknown action: " + action.type);
  }
}

02Share it with two contexts and custom hooks

The provider owns the state; any component below reads it with a hook. Components never import the provider's internals, so the next step can swap useReducer for a store without touching them.

src/state/TasksProvider.jsxwhole filejsx
const TasksContext = createContext(null);
const DispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, SEED_TASKS);
  return (
    <TasksContext value={tasks}>
      <DispatchContext value={dispatch}>{children}</DispatchContext>
    </TasksContext>
  );
}
export const useTasks = () => useContext(TasksContext);
export const useDispatch = () => useContext(DispatchContext);
Share it with two contexts and custom hooksdiagram
Rendering diagram…

03Local state stays local

The new task's text belongs to AddTask; "is this card being edited" belongs to that TaskCard. They are useState in those components, not actions in the store. Only data other components need goes up.

04Milestone 3: move the state into an external store

The store holds the tasks in a variable, applies the reducer in dispatch, saves, and calls every listener. getSnapshot returns the stored array itself, so it stays the same until a dispatch.

Milestone 3: move the state into an external storediagram
Rendering diagram…

05Read the store in the provider

Only the provider changes: useSyncExternalStore(store.subscribe, store.getSnapshot) replaces useReducer, and store.dispatch replaces dispatch. Every other component keeps using useTasks() and useDispatch().

src/state/TasksProvider.jsxwhole filejsx
export function TasksProvider({ children }) {
  const tasks = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return (
    <TasksContext value={tasks}>
      <DispatchContext value={store.dispatch}>{children}</DispatchContext>
    </TasksContext>
  );
}

06Handle missing and broken saved data

The first visit has nothing saved (getItem returns null, and JSON.parse(null) is null). An old version may have saved a different shape. Someone may have edited the value by hand. readSaved accepts only an array and falls back to the seed otherwise. Real apps also store a version number with the data and migrate old shapes.

Try it yourself

  1. 1

    Prove it saved

    In milestone 3, add a task called Buy milk and move it to Done. Predict what Reload from storage shows, then press it. Now press Run (which empties the preview's storage) and predict again.

  2. 2

    Add an action

    Add a "cleared-done" action to the reducer that removes every task in Done, and a Clear done button that dispatches it. You touch the reducer and one button; nothing else changes.

  3. 3

    Break the saved data

    In the persistent store example, change the broken value to "[1, 2". Predict the console. Then save a valid but wrong shape, "\"hello\"": does saved ?? fallback protect you? (No: it returns the string. Milestone 3's Array.isArray check is stricter.)

Code & diagrams

Milestone 3: reducer + context + a store saved to localStorage React 19+ New tab

Add, edit (✎, then Enter or Escape), move and delete tasks. Then press **Reload from storage**: the board re-reads what was saved, as a page reload would. The preview's localStorage is in memory, so pressing Run starts fresh. Milestone 2 is the same app with `useReducer` in the provider instead of the store.

Preview

Press Run to see the page.

Console

Nothing logged yet.
The reducer is a plain function New tab

The old array is untouched, the unchanged task object is reused (so `memo` can skip it later), and an unknown action fails loudly.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

[ 't1:done', 't2:todo' ]
start unchanged: todo | t2 object reused: true
Unknown action: renamed
A persistent store: save, reload, survive broken data React 18+ New tab

The same store works for any reducer. Code outside React (here the script at the bottom) can dispatch and read it, and React stays in step.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

in storage: 2
after reload: 41
bad saved data, using the fallback
after a broken value: 0
button says: Saved count: 0

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Build a new snapshot on every call

Write the store's snapshot as const getSnapshot = () => JSON.parse(localStorage.getItem("tasks")); and read it with useSyncExternalStore(subscribe, getSnapshot).

terminal
$ Console
── what you'll see ──
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
An error occurred in the <App> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

Break #2

Change the array in place

In the reducer's moved case, write const t = tasks.find((x) => x.id === action.id); t.status = action.status; return tasks;.

terminal
$ Preview
── what you'll see ──
Clicking → does nothing on screen, although the task's status really changed in memory. Reload from storage then shows the move.

Myth vs fact

Myth

Saving in a useEffect is just as good as a store.

Fact

It works for simple cases, but React state and the saved copy are two sources of truth, the first render shows the seed before the effect runs, and non-React code can't read the data. A store keeps one copy and reads storage synchronously before the first render.

Myth

Context is a state manager.

Fact

Context only passes a value down. The state lives in useReducer or in the store; context delivers it. Every consumer re-renders when the value changes, which is why data and dispatch are split (Topic 9.3).

Myth

localStorage is a safe place for anything.

Fact

It is readable by any script on the page, so never store tokens or secrets there (Topic 21.x). It holds strings only, is limited to a few MB, and blocks the main thread while reading and writing.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    useSyncExternalStore takes a third argument, getServerSnapshot, used during server rendering and hydration. With localStorage there is no server value, so a server-rendered app returns the seed there and the real saved tasks after hydration (Topic 20.6).

  • ▸

    Writing to localStorage on every dispatch is fine for a small board. For frequent updates (typing), debounce the write or save in requestIdleCallback; for large data, use IndexedDB, which is asynchronous.

  • ▸

    Zustand (Topic 15.x) is essentially this store with selectors: create((set) => ...) plus a persist middleware that does the localStorage part, and its React hook is built on useSyncExternalStore. Writing the small version once shows you there is no magic.

  • ▸

    Store a schema version with saved data ({ version: 2, tasks }) and migrate on read. Without it, the first release that renames a field breaks every returning user's board.

Remember this

  1. 1

    A reducer (Topic 9.4) is a pure function (tasks, action) => newTasks. An action is a plain object saying what happened: { type: "moved", id: "t3", status: "done" }. All four changes (added, edited, moved, deleted) live in one switch, so the whole behaviour of the data is readable in one screen and testable without React. It never changes the old array: it returns a new one with map, filter and spread, reusing the task objects that didn't change (that reuse is what makes memo work in 27.5).

  2. 2

    Reducer + context (Topic 9.6): milestone 2 keeps the reducer in useReducer inside a TasksProvider component and shares the result through two contexts: TasksContext for the data and DispatchContext for the dispatch function. Components read them with small custom hooks, useTasks() and useDispatch(), instead of calling useContext everywhere. Two contexts matter: AddTask only needs dispatch, which never changes, so it doesn't re-render when tasks change. React 19 lets you write <TasksContext value={...}> directly; older code writes <TasksContext.Provider value={...}>.

  3. 3

    Persistence means the data outlives the page. localStorage is the browser's small key-value storage (strings only, about 5 MB per site, kept after the tab closes). The tasks are saved with JSON.stringify and read back with JSON.parse. Reading must be defensive: the key may be missing (first visit), or hold broken JSON or an old shape from an earlier version of the app, so the read falls back to the seed data instead of crashing. In Hectal's preview localStorage lives in memory and resets on every Run.

  4. 4

    Why an external store (milestone 3, Topic 10.3)? You could keep useReducer and save in a useEffect. That works, but the saved copy and React's copy are two sources of truth, the first render shows the seed data before the effect loads, and code outside React can't read or change the tasks. An external store is a plain JavaScript object that holds the tasks, applies the same reducer in dispatch, saves to localStorage, and tells subscribers about changes. React reads it with useSyncExternalStore(subscribe, getSnapshot) (React 18): subscribe registers a listener and returns an unsubscribe function; getSnapshot returns the current value. The reducer is reused unchanged.

  5. 5

    **getSnapshot must return the same value until something changes.** React calls it on every render and compares the result with Object.is. If it builds a new array each time (for example JSON.parse(localStorage.getItem(key))), React sees a change on every call and loops until it throws. The store keeps the parsed array in a variable and replaces it only in dispatch and reload. Using a store also prevents tearing (different parts of the screen showing different versions of the data during a concurrent render).

  6. 6

    Other tabs: the browser fires a storage event in every *other* tab of the same site when localStorage changes. A store can listen for it in subscribe, re-read the saved tasks and notify React, so two open tabs stay in step. The preview's in-memory storage fires no such events, so the live example offers a Reload from storage button that re-reads the saved value, which is what a page reload does.

Explain it without notes

01

Why split the tasks and dispatch into two contexts?

02

How does useSyncExternalStore work, and why must getSnapshot return a cached value?

03

Compare saving tasks with useReducer + useEffect against an external store.

04

How would you keep two open tabs of the board in sync?

Practice

01

Add a "cleared-done" action to tasksReducer and show the full case.

02

Write a useTaskCount(status) custom hook that returns how many tasks have that status.

Trade-offs

  • ↔

    Reducer + context is built in and enough for one app's shared data; for many independent slices, fine-grained subscriptions (selectors) or a library such as Zustand avoid re-rendering every consumer.

  • ↔

    localStorage is synchronous and simple but small and string-only; IndexedDB handles large or binary data but is asynchronous and more code.

  • ↔

    Saving everything locally makes the app work offline but creates a second copy of server data that must be reconciled (27.4).

Done when you can

  • I can write a pure reducer with past-tense actions and test it without React.

  • I can share state and dispatch with two contexts and custom hooks.

  • I can build an external store and read it with useSyncExternalStore.

  • I can save to and restore from localStorage safely, including broken data.

  • I know why getSnapshot must return a cached value.