How the App Remembers Things
Watch an operator's screen when a camera pans. Three things update at once: the image in the camera tile, the field-of-view cone on the map, and the pan/tilt/zoom readout in the control panel. Those three pieces of UI live in completely different corners of the component tree. Nobody passed a prop. No context provider re-rendered the world.
So how did they all find out?
That's the question this chapter answers. By the end of it you'll know where every piece of client-side state in this app lives, how a change in one place reaches every screen that cares, and — just as important — why a change doesn't reach the screens that don't.
Two kinds of memory
Everything the browser knows falls into one of two buckets, and the first decision you'll make on any feature is which bucket your data belongs in.
Server state is anything the backend is the authority on: the camera fleet, user profiles, alert history. We never own this data — we hold a cached copy of the server's answer, and TanStack Query manages that cache (fetching, refetching, staleness) for us.
Client state is what the app itself knows: which camera the operator has open, which filters are active, whether the sidebar is collapsed, the live pan angle that just arrived over a socket. There is no server to re-ask. Someone has to hold this — and in this app, that someone is Zustand.
Think of it like a fire-camera control room. TanStack Query is the stack of printed reports from headquarters — periodically refreshed, always slightly behind reality. Zustand is the whiteboard on the wall: anyone in the room can walk up and write on it, and everyone who's watching that corner of the board sees the change instantly.
Your first store
Zustand's entire pitch is that a store is just a hook. No provider, no reducer, no dispatch. Here is a store as small as one could be:
import { create } from 'zustand';
const useCounterStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}));
And here's a component using it:
function Counter() {
const count = useCounterStore(state => state.count);
return <span>{count}</span>;
}
That function you pass in — (state) => state.count — is called a selector, and it's the
single most important idea in this chapter. A component doesn't subscribe to the store. It
subscribes to the value the selector returns. If a write to the store leaves that value
untouched, the component doesn't re-render. That is the whole trick behind "three parts of the
screen update, and nothing else does."
Don't take my word for it — watch it happen. Both components below count their own renders. Click the button and see which one moves:
// A store, stripped to its essence: state + listeners. function createStore(initial) { let state = initial; const listeners = new Set(); return { getState: () => state, setState: (partial) => { state = { ...state, ...partial }; listeners.forEach((l) => l()); }, subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); }, }; } const store = createStore({ pan: 45, name: 'Bald Mountain North' }); // "useStore(selector)" — the same shape Zustand gives you. function useStore(selector) { return React.useSyncExternalStore(store.subscribe, () => selector(store.getState())); } function PanReadout() { const pan = useStore((s) => s.pan); const renders = React.useRef(0); renders.current += 1; return <div>🎥 Pan: {pan}° — rendered {renders.current}×</div>; } function NameBadge() { const name = useStore((s) => s.name); const renders = React.useRef(0); renders.current += 1; return <div>🏔️ {name} — rendered {renders.current}×</div>; } function App() { return ( <div style={{ display: 'grid', gap: 8, fontSize: 15 }}> <PanReadout /> <NameBadge /> <button onClick={() => store.setState({ pan: store.getState().pan + 15 })}> Pan camera +15° </button> </div> ); } render(<App />);
The pan readout climbs; the name badge stays frozen at one render. Multiply that by ~1,400 camera tiles and a poll every ten seconds, and you understand why this app is built on selectors.
One camera, four owners
Open stores/ and you'll find about thirty store files — one per concern, no provider anywhere.
You don't need to memorize them. You need exactly one mental model, because it explains most of
the confusing bugs you'll ever chase here:
a "camera" is not one piece of state. Four stores each own a different slice of it.
staticCameraStoreowns the parts that don't move: coordinates, name, site, capability flags. Written once per static fetch.realTimeCameraStoreowns the parts that change minute to minute: pan, tilt, zoom, latest image, AI-detection flags. Written by polling and by SignalR pushes.pinsStoreowns visibility: which cameras are inside the map viewport, and which tiles are scrolled into view. Written by the map and by an IntersectionObserver.cameraSelectedStoreowns attention: the one camera the operator has open. Written by click handlers.
Ask "where does pan come from?" and the answer is always realTimeCameraStore. Ask "where is this
camera on the map?" — always staticCameraStore. The four update on completely different clocks:
days, seconds, gestures, clicks.
The most confusing bugs in this app share one root cause: a field copied from one store into
another. The copy is frozen at copy-time while the original keeps updating on its own clock, and
the two silently disagree forever after. If you need pan inside a map handler, read it from
realTimeCameraStore at that moment — don't stash it somewhere closer.
Writing a store, step by step
When you eventually add a store of your own, there are four house rules. Each one exists because of a real bug class, so let's take them in order.
Step 1 — one file, one concern, registered with DevTools. Every store wraps its creator in
devtools with a name of ALERTCalifornia/<storeName>. That makes your store show up as its own
labelled instance in Redux DevTools, with trace on in development — so "what wrote this value?"
is answered by a stack trace instead of an archaeology dig.
Step 2 — pick the right shape for reads. The camera stores hold a Map keyed by cId
instead of an array, because at ~1,400 cameras an Array.find() on every hover and every poll
adds up, and a Map.get() doesn't. If your data is looked up by id on hot paths, index it once.
Step 3 — every write hands back a new reference. Zustand detects change by comparing
references. This is the classic footgun with Map:
// ❌ Nothing re-renders. Zustand sees the same Map it already had.
state.realTimeCameraData.set(cId, next);
// ✅ A new Map is a new reference.
const copy = new Map(state.realTimeCameraData);
copy.set(cId, next);
set({ realTimeCameraData: copy });
Both camera stores already do the copy for you — you only carry this rule into new stores.
Step 4 — subscribe narrowly. Calling useSomeStore() with no selector subscribes to
everything, so any write anywhere re-renders your component. Select the slice you need, and when
you need several fields, wrap the selector in useShallow so the fresh object literal it returns
each render doesn't defeat the comparison:
const { getCameraById } = useRealTimeCameraStore(
useShallow(state => ({ getCameraById: state.getCameraById })),
);
Going deeper
Two patterns show up once you leave ordinary component code, and both look strange until you know why they exist.
Reading without subscribing. Event handlers registered on ArcGIS, SignalR callbacks, plain utilities — none of these are part of a render, so they shouldn't subscribe to anything. They read the current value directly:
const { setGlobalPanoOffsetDegrees } = useStaticCameraStore.getState();
getState() is the door into a store from outside React. Inside a component's render, use the
hook; inside a callback that outlives renders, use getState().
Wrapping up
Client state lives in Zustand stores; server state lives in TanStack Query. Components subscribe through selectors, so a write only re-renders the components whose selected value actually changed. A camera is sliced across four stores that update on four different clocks, and copying between them is the bug you'll regret most.
Next, let's meet the two biggest stores properly — and see the surprisingly subtle rules that govern how live camera data gets written: One Camera, Two Clocks.