State Management Across the App

Food Tracker (React + Express)

Chapter 10 · State Management Across the App

Every feature so far has managed its own state: useExpiryAlerts, useDebouncedSearch, RecipeSuggestions' own useState. That worked fine when each feature only needed to know about itself. It stops being enough the moment one action needs to update several of them at once.

The Problem, Concretely

Chapter 8's markUsed called refresh() directly on the one hook it happened to have a reference to — useExpiryAlerts. But marking an item used should really affect three separate features at once:

  • Alerts (Chapter 6) — the item should disappear, since it's no longer active.
  • Search results (Chapter 7) — if the user's current search happens to include this item, its status should update to reflect "used."
  • Recipe suggestions (Chapter 9) — an ingredient that's no longer expiring shouldn't keep influencing recipe matches.

Wiring markUsed to call three separate refresh functions directly works today, but it doesn't scale: every new feature that cares about item changes means going back and adding one more manual call at every single place items get mutated (add, mark used, and anything added later). That's a real, growing coordination cost, not a hypothetical one.

A Shared Change-Notification Context

Rather than wiring components directly to each other, one shared signal any component can both trigger and listen to:

// context/ItemsContext.jsx import { createContext, useContext, useState, useCallback } from "react"; const ItemsContext = createContext(null); function ItemsProvider({ children }) { const [version, setVersion] = useState(0); const notifyChange = useCallback(() => setVersion((v) => v + 1), []); return ( <ItemsContext.Provider value={{ version, notifyChange }}> {children} </ItemsContext.Provider> ); } function useItemsContext() { return useContext(ItemsContext); }

version is deliberately just a number, not the actual item data — this Context coordinates when to refetch, it doesn't try to own or duplicate every feature's own data-fetching logic.

Wiring Every Consumer Through the Same Signal

Chapter 6's hook, updated to refetch whenever version changes, instead of only on mount:

// hooks/useExpiryAlerts.js (updated) function useExpiryAlerts() { const { version } = useItemsContext(); const [alerts, setAlerts] = useState([]); useEffect(() => { fetch("/api/items/alerts").then((r) => r.json()).then(setAlerts); }, [version]); return { alerts }; }

And Chapter 8's markUsed, now calling the shared signal instead of one specific hook's own refresh function:

const { notifyChange } = useItemsContext(); const markUsed = async (id) => { await fetch(`/api/items/${id}/use`, { method: "PATCH" }); notifyChange(); };

RecipeSuggestions and the search hook get the same one-line change: depend on version in their own useEffect, instead of being individually wired to whatever action happened to trigger the update.

What actually changed here
Before this chapter, every place items get mutated needed to know about every feature that cares — an O(features × mutations) wiring problem that only grows as the app grows. After this chapter, a mutation only needs to know about one thing: call notifyChange(). Every feature that cares about item changes subscribes to version on its own, entirely independently of whatever action happened to trigger the change. Adding a tenth feature later means that feature subscribes to version itself — it does not mean going back to add one more call inside markUsed, addItem, and everywhere else a mutation happens.
This is a real limit, named honestly
Bumping version causes every component consuming ItemsContext to re-render, even ones whose own displayed data didn't actually change as a result. At this app's own scale — a handful of features, infrequent mutations (a user adding or using an item, not hundreds of updates per second) — that's genuinely invisible. It would become a real performance concern in an app with many more Context consumers and much more frequent updates, where a more granular state library (Zustand, Jotai, or splitting into several smaller contexts) would be the more honest choice instead of one shared version counter.
Context solves coordination, not caching
This pattern intentionally does not try to hold a single shared copy of every item, avoiding re-fetching, in one Context-level cache — each hook still fetches its own view from its own Express route (alerts, search, or recipes). A library like React Query or SWR exists specifically to add that caching layer on top of this same coordination idea; this course keeps the simpler version, since re-fetching a small SQLite query on each relevant change is genuinely fast enough not to need it.

Where This Course Is Headed

Deployment next — serving the built React app from the same Express process, and the environment configuration that goes with a real deployment.

Hands-On Exercises

Exercise 1

Explain the concrete coordination problem with Chapter 8's original approach (markUsed calling refresh() directly) once a third feature, recipe suggestions, also needs to react to the same mutation.

📄 View solution
Exercise 2

Explain why version is stored as a plain number rather than the actual items array, and what this Context is and isn't responsible for as a result.

📄 View solution
Exercise 3

Explain the honest limit named in this chapter's own warn-box, and describe a scenario (in terms of app size or update frequency) where that limit would actually start to matter in practice.

📄 View solution

Chapter 10 Quick Reference

  • The problem: Chapter 8's direct refresh() call only reaches one hook — doesn't scale as more features need to react to the same mutation
  • ItemsContext: holds one plain number, version, plus notifyChange() to bump it
  • Every relevant hook: depends on version in its own useEffect, refetching its own data independently
  • The real change: a mutation only calls notifyChange() once — it no longer needs to know which features are listening
  • Honest limit: every Context consumer re-renders on every version bump — fine at this scale, a real cost at a much larger one
  • Not a caching layer: each hook still fetches its own data from its own route; a library like React Query would add caching on top of this same idea
  • Next chapter: Deployment