State management
Learn predictable JavaScript state with a single source of truth, pure reducers, immutable updates, tiny stores, and state machines.
- 01Name the owner of stateSeparate local UI state, shared app state, server cache state, URL state, and form drafts.
- 02Build a tiny storeUse
getState,subscribe,unsubscribe, anddispatchwith pure reducers and action objects. - 03Prevent impossible updatesUse immutable updates, derived state, and finite state machines to keep growing apps predictable.
State is data that changes over time
A JavaScript app is easy while all data lives in one function call. It becomes harder when the same user action affects the header, a form, a URL, cached server data, and a result panel. State management is the discipline of naming the owner of each changing value and making updates predictable.
State is data that changes over time and affects what the app renders or does. State management chooses one owner, one update path, and a clear way for other code to read or derive values from it.
State is not only a global store. It can be local UI state, server/cache state, URL state, or a form draft. The trick is choosing the smallest reliable owner. The next lesson on app architecture connects those owners to components and boundaries; this lesson stays focused on state itself.
| Kind | Typical owner | Examples |
|---|---|---|
| Local UI state | One component owns it | A menu is open, a tab is selected, a tooltip is visible. |
| Shared app state | Several features read it | Cart items, authenticated user, feature flags, selected workspace. |
| Server/cache state | The server owns the truth | Fetched products, profile data, paginated search results, optimistic updates. |
| URL state | The address bar owns it | Search query, filters, sort order, page number, route params. |
| Form state | A form owns a draft | Typed values, validation messages, touched fields, submit status. |
If every teller kept a private sticky note for the account balance, the bank would be a mess. A ledger gives everyone one place to read from and one transaction process to write through. State management gives your app the same kind of discipline.
- In real life: The ledger is the official record
- In JavaScript: The store is the source of truth
- In real life: Transactions are appended facts
- In JavaScript: Actions describe what happened
- In real life: Rules compute the balance
- In JavaScript: Reducers compute the next state
- In real life: Statements are derived views
- In JavaScript: UI reads and derives values
Where the analogy stops: A bank ledger is mostly append-only and audited by humans. App state can be recomputed, cached, persisted, or discarded depending on the owner and user flow.
Compute derived state instead of storing two truths
A common bug is storing both cart.items and cartItemCount. Remove an item and forget to update the count, and the header disagrees with the cart list. Store the source data; compute the count and total when you need them.
const cart = [ { name: "Tea", price: 12, qty: 2 }, { name: "Mug", price: 5, qty: 1 },]; const itemCount = cart.reduce((total, item) => total + item.qty, 0);const total = cart.reduce((sum, item) => sum + item.price * item.qty, 0); console.log(itemCount);console.log(total.toFixed(2));Line 1 stores the source array. Lines 6 and 7 derive facts from that array. If the cart changes, re-running those expressions produces fresh values without syncing a second field.
A single source of truth
STEP THROUGHA store is just an object with a few promises: it owns the current state, readers can subscribe, and writes go through dispatch. The subscription idea is related to the observer pattern, but here the main point is that subscribers do not own their own cart copies.
A single source of truth means everyone reads the same store. Step through one dispatch: action in, reducer returns a new state, subscribers react, and unsubscribe stops future notifications.
script
let state = initialState; const listeners = new Set(); return { getState() { return state; }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, dispatch(action) { const previousState = state; state = reducer(state, action); if (previousState !== state) { listeners.forEach((listener) => listener(state, action, previousState)); } return action; }, };} function cartReducer(state, action) { switch (action.type) { case "cart/add": return { ...state, items: [...state.items, action.payload] }; default: return state; }} const store = createStore(cartReducer, { items: [] });const unsubscribe = store.subscribe((state, action) => { console.log(action.type + ": " + state.items.length);}); store.dispatch({ type: "cart/add", payload: { id: "tea", name: "Tea" } });unsubscribe();store.dispatch({ type: "cart/add", payload: { id: "mug", name: "Mug" } });console.log(store.getState().items.map((item) => item.id).join(", "));Read the step-through like a loop: dispatch(action) sends a fact, the reducer returns the next state, the store replaces its snapshot, and subscribers update their views. The unsubscribe function is just as important as subscribe, because removed UI should stop listening.
Redux popularized this shape, but the idea is plain JavaScript: one state value, plain action objects, pure reducers, and subscribers. Framework libraries add scheduling, devtools, middleware, selectors, or fine-grained subscriptions on top.
Reducers and actions
PURE UPDATE RULESA reducer is a pure function: (state, action) => nextState. The action is plain data such as { type: "cart/add", payload: item }. The reducer should not fetch, write web storage, mutate its input, or depend on time. That is exactly why the pure functions lesson matters here.
Given the same previous state and the same action, a reducer should return the same next state without changing anything outside itself. Side effects belong around dispatch, not inside the reducer.
Action creators are tiny helpers that return those action objects. Slice reducers keep unrelated areas separate: a cart reducer updates cart items, a UI reducer updates whether the cart drawer is open, and a root reducer combines them.
const addItem = (item) => ({ type: "cart/add", payload: item });const toggleCart = () => ({ type: "ui/toggle-cart" }); function cartReducer(state = { items: [] }, action) { if (action.type === "cart/add") { return { ...state, items: [...state.items, action.payload] }; } return state;} function uiReducer(state = { cartOpen: false }, action) { if (action.type === "ui/toggle-cart") { return { ...state, cartOpen: !state.cartOpen }; } return state;} function combineReducers(reducers) { return (state, action) => { let changed = false; const next = {}; for (const key of Object.keys(reducers)) { next[key] = reducers[key](state[key], action); changed = changed || next[key] !== state[key]; } return changed ? next : state; };} const reducer = combineReducers({ cart: cartReducer, ui: uiReducer });let state = { cart: { items: [] }, ui: { cartOpen: false } };state = reducer(state, addItem({ id: "tea" }));state = reducer(state, toggleCart());console.log(state.cart.items.length);console.log(state.ui.cartOpen);Lines 1 and 2 build action objects. Lines 4 and 11 keep two slices independent. Lines 18 through 27 make a root reducer: call each slice, track whether any slice reference changed, and reuse the old root object when nothing changed.
Immutable updates make change visible
REFERENCE CHECKSJavaScript variables hold references to objects and arrays. The objects and references lesson explains why two names can point at the same object. State management uses that fact on purpose: when an update returns a new reference, prevState !== nextState is a cheap signal that something changed.
Copy only the path that changes. Use object spread from spread and rest, arrays like map and filter, and copying methods such as toSorted and with. Keep old snapshots and you get undo, replay, and time travel.
const board = { user: { name: "Asha", badges: ["member"] }, tasks: [ { id: "draft", title: "Draft reducer", done: false }, { id: "ship", title: "Ship lesson", done: false }, ], priorities: ["low", "medium", "high"],}; const nextBoard = { ...board, user: { ...board.user, badges: [...board.user.badges, "reviewer"] }, tasks: board.tasks.map((task) => task.id === "draft" ? { ...task, done: true } : task, ), priorities: board.priorities.with(1, "normal").toSorted(),}; console.log(board.tasks[0].done);console.log(nextBoard.tasks[0].done);console.log(board.tasks === nextBoard.tasks);console.log(nextBoard.priorities.join(", "));Line 10 creates a new board. Line 12 copies a nested object and array. Lines 13 through 15 replace only the matching task. Line 16 uses copying array methods; the original priorities array is still available for history or comparison. For more patterns, see immutability.
This bug mutates state in place. Step through it and watch the subscriber miss a real data change because the state reference never changes.
script
function createStore(reducer, initialState) { let state = initialState; const listeners = new Set(); return { getState() { return state; }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, dispatch(action) { const previousState = state; state = reducer(state, action); if (previousState !== state) { listeners.forEach((listener) => listener(state, action)); } }, };} function badCartReducer(state, action) { if (action.type === "cart/add") { state.items.push(action.payload); return state; } return state;} const renders = [];store.subscribe((state) => { renders.push("rendered " + state.items.length);}); store.dispatch({ type: "cart/add", payload: { id: "tea" } });console.log(renders.length);console.log(store.getState().items.length);The mutation lab is the bug in slow motion. The cart really changes, but the outer state reference does not. A subscriber or UI selector that relies on reference equality can skip the update and display stale information.
const template = { user: { name: "Asha" }, created: new Date("2026-09-26T00:00:00Z"),}; const copy = structuredClone(template);copy.user.name = "Mina"; console.log(template.user.name);console.log(copy.user.name);console.log(Object.prototype.toString.call(copy.created));structuredClone is useful for deep-copying many data values, including Dates. It can throw for functions and DOM nodes, and cloning a whole state tree can do far more work than a targeted reducer copy. Preserve sharing when you can.
// Conceptual Immer-style code. Immer is not installed in this lesson.const nextState = produce(state, (draft) => { draft.cart.items.push({ id: "tea", name: "Tea" }); draft.ui.cartOpen = true;});Immer-style libraries let you write draft mutations and produce immutable results. This is ergonomic, but the principle is the same: the committed state must be a fresh immutable snapshot. Immer is not installed in this lesson, so the block is marked as conceptual.
State machines make impossible states impossible
FINITE STATESReducers are good for data updates. State machines are better when a flow has named stages and invalid transitions. A request can be idle, loading, success, or error. It should not be both loading and success, and it should not submit again while already loading.
A state machine keeps a list of finite states, events, and transitions. Step through valid and invalid events to see how impossible states disappear.
script
idle: { SUBMIT: "loading" }, loading: { RESOLVE: "success", REJECT: "error" }, success: { RESET: "idle" }, error: { RETRY: "loading", RESET: "idle" },}; function transition(state, event) { const nextValue = transitions[state.value]?.[event.type]; if (!nextValue) return state; return { value: nextValue, context: { ...state.context, lastEvent: event.type }, };} let state = { value: "idle", context: { lastEvent: null } };for (const event of [{ type: "SUBMIT" }, { type: "SUBMIT" }, { type: "RESOLVE" }]) { const next = transition(state, event); console.log(state.value + " + " + event.type + " -> " + next.value); state = next;}The interpreter is small: look up the event in the transition table for the current state. If no transition exists, return the current state. Larger statecharts add hierarchy, parallel states, guards, and effects. Libraries such as XState package those ideas, but you can learn the core with this tiny interpreter.
Practical state choices
PLAYGROUNDThe first question is not “Which library?” It is “Who owns this value?” A one-component tooltip should stay local. A server response should usually be cache state. A shareable filter belongs in the URL, which the URL objects lesson covers. A cart read by multiple features needs shared app state.
isHelpPopoverOpeninside one button component- Cart items used by the header badge and checkout page
- Fetched product list with loading and stale status
?sort=price&page=2in the address bar- Unsubmitted email text in a newsletter form
- Current signed-in user name shown in many places
- Cached
/api/profileresponse with last fetched time - Search text that must survive refresh and be linkable
Sort each card by the smallest reliable owner. If a value must be shareable, think URL. If the server owns the truth, think cache.
const initialState = { todos: [{ id: "draft", text: "Draft reducer", done: false }], filter: "all",}; function todoReducer(state, action) { switch (action.type) { case "todo/add": return { ...state, todos: [...state.todos, action.payload] }; case "todo/toggle": return { ...state, todos: state.todos.map((todo) => todo.id === action.payload.id ? { ...todo, done: !todo.done } : todo, ), }; case "filter/set": return { ...state, filter: action.payload }; default: return state; }} const selectOpenCount = (state) => state.todos.filter((todo) => !todo.done).length;let state = initialState;state = todoReducer(state, { type: "todo/add", payload: { id: "ship", text: "Ship lesson", done: false },});state = todoReducer(state, { type: "todo/toggle", payload: { id: "draft" } });console.log(selectOpenCount(state));- Draft reducer
open
@@init
The store starts from one snapshot. Dispatch actions to create history, then drag the slider back to read old states.
The playground is small enough to read. Every button dispatches an action. The reducer returns a new snapshot. The action log and slider demonstrate why immutable state makes undo and time travel possible: old snapshots still mean what they meant when they were created.
const preferences = { theme: "dark", sidebar: "open" };localStorage.setItem("cf-preferences", JSON.stringify(preferences)); const restored = JSON.parse(localStorage.getItem("cf-preferences") ?? "{}");const url = new URL("https://example.com/products?sort=price");url.searchParams.set("page", "2"); console.log(restored.theme);console.log(url.pathname + url.search);Persistence is a storage choice, not a second owner. Use localStorage for private preferences that should survive reloads. Use the URL for state someone should be able to refresh, bookmark, or send to a teammate. The inline runner is sandboxed without storage access, so this browser API example is shown as a page-context fragment.
Libraries give you conventions and tooling, not permission to skip the model. Common choices include:
- Redux Toolkit: reducer-first state, action history, devtools, and Immer-powered reducer ergonomics.
- Zustand: small store functions with selective subscriptions and little ceremony.
- Pinia: Vue-focused stores with actions, getters, and devtools integration.
- Signals: fine-grained reactive values; the observer pattern lesson covers the subscription side.
- Statecharts/XState: explicit finite states, events, guards, and effects for flows where transitions matter.
Common misconceptions
- “All state belongs in a global store.” Local UI toggles and temporary form drafts often belong close to the component that owns them.
- “If I store the count, the UI is faster.” Store source data and derive counts unless measuring proves the computation is expensive.
- “A reducer can mutate if the app still looks right.” Mutation breaks reference equality, undo, time travel, and skipped-render logic.
- “State machines are only for huge apps.” They are useful whenever a small flow has invalid transitions, such as submit while loading.
- “localStorage is state management.” Storage persists a snapshot. Your app still needs one owner and one update path after hydration.
- “Signals replace thinking about ownership.” Signals change how subscriptions are tracked; they do not decide what should be local, shared, cached, or URL-owned.
| Tool | Best for | Strength | Watch out for |
|---|---|---|---|
| Local variables | A single run of a function | Fast and simple | Disappear after the call; not shared with the UI |
| Single store | Shared app data | One write path, subscriptions, action log, undo | Can become a dumping ground if every tiny toggle goes in it |
| State machine | Flows with finite stages | Blocks invalid transitions like submit while loading | Extra structure for simple counters or open/closed toggles |
| Signals | Fine-grained reactive values | Small updates can notify precise readers | Subscriptions/reactivity rules differ by library |
Notice how this lesson links to nearby patterns rather than duplicating them. The observer pattern handles subscriptions and signals in depth; app architecture handles MVC, components, and dependency injection; structural and behavioral patterns cover commands, strategy, and undo-friendly actions.
Practice exercises
5 EXERCISESRead the store code and type the two printed values in order.
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispatch(action) {
state = reducer(state, action);
listeners.forEach((listener) => listener(state, action));
},
};
}
const reducer = (state, action) =>
action.type === "add" ? { ...state, count: state.count + action.payload } : state;
const store = createStore(reducer, { count: 0 });
store.subscribe((state) => console.log(state.count));
store.dispatch({ type: "add", payload: 2 });
store.dispatch({ type: "add", payload: 3 });The first dispatch changes count from 0 to 2. The second changes it from 2 to 5, so the logs are 2 then 5.
Fill in the ui/toggle-cart branch with an immutable return statement.
function uiReducer(state, action) {
switch (action.type) {
case "ui/toggle-cart":
// Return the next state here.
default:
return state;
}
}return { ...state, cartOpen: !state.cartOpen };The return statement creates a new object and toggles only the cartOpen field.
Which expression in the snippet proves the same object reference was reused?
const state = { items: [] };
const next = state;
next.items.push({ id: "tea" });
console.log(state === next);
console.log(state.items.length);const state = { items: [] };
const next = { ...state, items: [...state.items, { id: "tea" }] };
console.log(state === next);
console.log(state.items.length);
console.log(next.items.length);The bug is proven by state === next. The fixed version creates a new outer object and a new items array.
The form machine blocks double submit. After two SUBMIT events, which state is it still in?
const transitions = {
editing: { SUBMIT: "submitting" },
submitting: { RESOLVE: "success", REJECT: "error" },
error: { CHANGE: "editing", SUBMIT: "submitting" },
success: { RESET: "editing" },
};
function transition(state, event) {
return transitions[state]?.[event] ?? state;
}
let state = "editing";
state = transition(state, "SUBMIT");
state = transition(state, "SUBMIT");
console.log(state);After the first submit, the machine is submitting. The second submit is invalid from that state, so the machine stays submitting.
A teammate wants to add a writable itemCount field beside cart. What should you store instead?
const cart = [{ qty: 2 }, { qty: 3 }];
const itemCount = cart.reduce((total, item) => total + item.qty, 0);
console.log(itemCount);Do not store itemCount as separate writable state. Compute it from cart with reduce so it cannot drift.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does a single source of truth mean in an app?
Choose an answer to see the explanation.
Question 2 of 8What does this tiny store print?
Read the code, then predictfunction createStore(reducer, initialState) { let state = initialState; const listeners = new Set(); return { subscribe(listener) { listeners.add(listener); }, dispatch(action) { state = reducer(state, action); listeners.forEach((listener) => listener(state, action)); }, }; } const reducer = (state, action) => action.type === "cart/add" ? { ...state, items: [...state.items, action.payload] } : state; const store = createStore(reducer, { items: [] }); store.subscribe((state, action) => console.log(action.type + ": " + state.items.length)); store.dispatch({ type: "cart/add", payload: { id: "tea" } });Choose an answer to see the explanation.
Question 3 of 8What is wrong with this reducer?
Read the code, then predictfunction reducer(state, action) { if (action.type === "cart/add") { state.items.push(action.payload); return state; } return state; } const state = { items: [] }; const next = reducer(state, { type: "cart/add", payload: { id: "tea" } }); console.log(state === next); console.log(next.items.length);Choose an answer to see the explanation.
Question 4 of 8Which update keeps the original array unchanged?
Choose an answer to see the explanation.
Question 5 of 8What does the state machine log after the invalid second submit?
Read the code, then predictconst transitions = { idle: { SUBMIT: "loading" }, loading: { RESOLVE: "success" }, }; const transition = (state, event) => transitions[state]?.[event] ?? state; let state = "idle"; state = transition(state, "SUBMIT"); state = transition(state, "SUBMIT"); console.log(state);Choose an answer to see the explanation.
Question 6 of 8Which value is derived state and should usually be computed?
Choose an answer to see the explanation.
Question 7 of 8Where should
?sort=price&page=2usually live?Choose an answer to see the explanation.
Question 8 of 8What does undo/time travel need from reducers?
Choose an answer to see the explanation.
Key takeaways
- State is changing data that affects behavior or rendering; not all state is global.
- A single source of truth prevents two writable copies from disagreeing.
- Reducers take previous state plus an action and return the next state without side effects.
- Immutable updates make reference equality, undo, and time travel practical.
- State machines make finite flows explicit and block invalid events.
- Use localStorage for private persistence and the URL for shareable navigation state.
Remember the one-liner.
Choose one owner for each changing value, derive everything you can, and make every transition explicit.
Up next: Structuring an application, where state choices meet components, MVC-style boundaries, and dependency injection.