cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

State management

Learn predictable JavaScript state with a single source of truth, pure reducers, immutable updates, tiny stores, and state machines.

By the end, you can
  • 01
    Name the owner of stateSeparate local UI state, shared app state, server cache state, URL state, and form drafts.
  • 02
    Build a tiny storeUse getState, subscribe, unsubscribe, and dispatch with pure reducers and action objects.
  • 03
    Prevent 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.

Plain definition

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.

Common kinds of state and who should own them
KindTypical ownerExamples
Local UI stateOne component owns itA menu is open, a tab is selected, a tooltip is visible.
Shared app stateSeveral features read itCart items, authenticated user, feature flags, selected workspace.
Server/cache stateThe server owns the truthFetched products, profile data, paginated search results, optimistic updates.
URL stateThe address bar owns itSearch query, filters, sort order, page number, route params.
Form stateA form owns a draftTyped values, validation messages, touched fields, submit status.
Real-life analogyA bank ledger, not sticky notes on every desk

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.

Derived cart count and totalPop out in the code editor (opens in a new tab)JavaScript
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 THROUGH

A 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.

Tiny store dispatch: action in, subscribers out
Step 0 of 8Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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(", "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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-style, not framework-locked

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 RULES

A 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.

Reducer rule of thumb

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.

Action creators and slice reducersPop out in the code editor (opens in a new tab)JavaScript
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 CHECKS

JavaScript 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.

Nested immutable updatePop out in the code editor (opens in a new tab)JavaScript
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.

Mutation bug: the state changed, the reference did not
Step 0 of 6Ready
Your turn: follow the blue line

This bug mutates state in place. Step through it and watch the subscriber miss a real data change because the state reference never changes.

Running in
  1. script
Next: line 31
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

structuredClone is a tool, not the default reducer strategyPop out in the code editor (opens in a new tab)JavaScript
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.

Immer-style produce, concept onlyJavaScript
// 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 STATES

Reducers 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.

State machine: valid and invalid events
Step 0 of 8Ready
Your turn: follow the blue line

A state machine keeps a list of finite states, events, and transitions. Step through valid and invalid events to see how impossible states disappear.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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;}
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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

PLAYGROUND

The 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.

Local UI state, shared app state, server cache, or URL state?
  • isHelpPopoverOpen inside one button component
  • Cart items used by the header badge and checkout page
  • Fetched product list with loading and stale status
  • ?sort=price&page=2 in the address bar
  • Unsubmitted email text in a newsletter form
  • Current signed-in user name shown in many places
  • Cached /api/profile response with last fetched time
  • Search text that must survive refresh and be linkable
Try it yourself
0 of 8 correct

Sort each card by the smallest reliable owner. If a value must be shareable, think URL. If the server owns the truth, think cache.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Todo store with action log and time travel
Todo reducer behind the playgroundPop out in the code editor (opens in a new tab)JavaScript
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));
Current snapshotindex 0
Filterall
Open count1
  • Draft reducer open
Action log
  1. @@init
Try it yourself

The store starts from one snapshot. Dispatch actions to create history, then drag the slider back to read old states.

This playground is intentionally small: one reducer, an action log, immutable snapshots, and a slider that reads history without mutating it.

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.

Persist private preferences and encode URL statePop out in the code editor (opens in a new tab)JavaScript
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.

Where libraries fit

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.
Choosing the right state tool
ToolBest forStrengthWatch out for
Local variablesA single run of a functionFast and simpleDisappear after the call; not shared with the UI
Single storeShared app dataOne write path, subscriptions, action log, undoCan become a dumping ground if every tiny toggle goes in it
State machineFlows with finite stagesBlocks invalid transitions like submit while loadingExtra structure for simple counters or open/closed toggles
SignalsFine-grained reactive valuesSmall updates can notify precise readersSubscriptions/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 EXERCISES
Exercise 1 · Warm-upPredict two dispatches

Read the store code and type the two printed values in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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 });

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeWrite a reducer case

    Fill in the ui/toggle-cart branch with an immutable return statement.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function uiReducer(state, action) {
      switch (action.type) {
        case "ui/toggle-cart":
          // Return the next state here.
        default:
          return state;
      }
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the mutation bug

      Which expression in the snippet proves the same object reference was reused?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const state = { items: [] };
      const next = state;
      next.items.push({ id: "tea" });
      console.log(state === next);
      console.log(state.items.length);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · ChallengeDesign a form machine

        The form machine blocks double submit. After two SUBMIT events, which state is it still in?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRemove derived state

          A teammate wants to add a writable itemCount field beside cart. What should you store instead?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const cart = [{ qty: 2 }, { qty: 3 }];
          const itemCount = cart.reduce((total, item) => total + item.qty, 0);
          console.log(itemCount);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What does a single source of truth mean in an app?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this tiny store print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function 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.

            3. Question 3 of 8What is wrong with this reducer?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function 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.

            4. Question 4 of 8Which update keeps the original array unchanged?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does the state machine log after the invalid second submit?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            6. Question 6 of 8Which value is derived state and should usually be computed?

              Choose an answer to see the explanation.

            7. Question 7 of 8Where should ?sort=price&page=2 usually live?

              Choose an answer to see the explanation.

            8. 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.

            CompleteFrontend Clear concepts. Working examples.