completefrontend
Open lab My progress
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
    Pick an owner for each valueTell apart local UI state, shared app state, server cache state, URL state, and form state.
  • 02
    Build a small storeUse getState, subscribe, and dispatch, with a reducer that turns each action into the next state.
  • 03
    Stop changes that make no senseCopy instead of changing in place, work out values instead of storing them twice, and use a state machine to block steps that must not happen.

State is data that changes over time

A small program is easy to follow when all its data lives inside one function. A web app is harder. One click can change the header, a form, the web address, and a list of results all at once. State management means two things:

  • You decide which one place owns each changing value.
  • You decide the one way that value is allowed to change.
Plain definition

State is data that changes while the app runs and changes what the app shows or does. A cart's items are state. So is "is the menu open?". State management picks one owner for each piece of state, one way to change it, and a clear way for other code to read it.

State does not all live in one big shared place. Some state belongs to one part of the page. Some comes from the server. Some lives in the web address. Some is a form the user is still filling in. The skill is to pick the smallest owner that works. The next lesson, app architecture, shows how those owners fit into a whole app. This lesson stays with state itself.

The table below lists five kinds of state. Read each row as: "this kind of state is owned by this, for example these values."

Five kinds of state and who owns each one
Kind of stateWho owns it?Examples
Local UI stateOne part of the page owns it.Is the menu open? Which tab is picked? Is the tooltip showing?
Shared app stateOne shared store owns it, because several parts read it.The cart's items, the signed-in user, the chosen workspace.
Server cache stateThe server owns it. The app keeps a copy.Fetched products, profile data, pages of search results.
URL stateThe web address owns it.The search words, filters, sort order, and page number.
Form stateThe form owns it until it is sent.What the user typed, error messages, whether the form is sending.
Real-life analogyOne shared shopping list

Imagine everyone at home kept their own shopping list. Two people might buy milk. Nobody might buy bread. With one shared list on the fridge, everyone sees the same answer.

In real life: Everyone checks the same list
In JavaScript: Every part of the page reads the same state
In real life: One person writes on the list
In JavaScript: One owner makes each change
In real life: Nobody keeps a private list
In JavaScript: No part of the page keeps its own copy to change
In real life: The list shows what to buy now
In JavaScript: The page shows the latest state

In code, that one shared place is called the single source of truth. Every part of the page reads from it, so they all agree.

Work out values instead of storing them twice

Here is a common bug. You store the cart's items in cart.items. You also store how many there are in cartItemCount. Then you remove an item and forget to change the count. Now the header says 3 and the cart shows 2.

The fix is simple. Store only the items. Work out the count and the total from the items when you need them. A value you work out from other state is called derived state.

Work out the cart's count and total from its itemsPop 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 cart: an array of items. Only this array is stored.

  • Line 6 works out the count. reduce walks through the array and adds up each item.qty, starting from 0.
  • Line 7 works out the total the same way: price times quantity for each item, added up.
  • Lines 9 and 10 print 3 and 29.00. The count is 3 because two teas count as two.

If the cart changes, you run lines 6 and 7 again and get fresh numbers. There is no second value to keep in step.

An empty cart still has a count of 0Pop out in the code editor (opens in a new tab)JavaScript
const cart = [];
const itemCount = cart.reduce((sum, item) => sum + item.qty, 0);

console.log(itemCount);

Line 2 starts reduce at 0. The cart has no items, so there is nothing to add. reduce just gives back its starting value, and line 4 prints 0.

A small function that reads or works out a value from state has a name: a selector. Working out a count like this is fast, so you can do it every time. Some apps save a result and reuse it while the inputs stay the same. That is called memoization. Only add it when the work is really slow, or when other code needs to get back the very same object each time.

Two parts of the page read one cartPop out in the code editor (opens in a new tab)JavaScript
const store = { cart: [{ name: "Tea", qty: 2 }] };
const headerCount = store.cart.reduce((sum, item) => sum + item.qty, 0);
const cartNames = store.cart.map((item) => item.name).join(", ");

console.log(headerCount);
console.log(cartNames);

Line 1 makes one store object that holds the cart. Line 2 works out the count for the header. Line 3 works out the item names for the cart page. Both read the same store.cart. The code prints 2 and Tea.

One cart called store.cart holds Tea, qty: 2. An arrow goes to a Header window that shows headerCount: 2. Another arrow goes to a Cart page window that shows cartNames: Tea.Tea, qty: 2cartstore.cartHeaderheaderCount: 2Cart pagecartNames: Tea
Both views read the same store.cart. Neither keeps its own copy, so they can't disagree.

A single source of truth

STEP THROUGH

A store is an object that holds the state for the whole app. The store below is small. It has three methods:

  • getState() gives back the current state.
  • subscribe(listener) saves a function to call after every change. That function is a subscriber. subscribe gives back another function. Call it to unsubscribe, which means "stop calling me".
  • dispatch(action) asks for a change. It is the only way to change the state.

An action is a plain object that says what happened, like { type: "cart/add", payload: { id: "tea" } }. The type names the change. The payload carries the data for it. A reducer is a function that takes the current state and an action, and gives back the next state. The next section looks at reducers closely.

Subscribers are like the observer pattern: they wait to be told about a change. The key point here is different: subscribers never keep their own copy of the cart. They read the store.

A small store: an action goes in, the subscriber runs
Step 0 of 8Ready
Your turn: follow the blue line

Follow one change from start to end: an action goes in, the reducer gives back a new state, and the subscriber runs. Then unsubscribe stops the subscriber.

Running in
  1. script
Next: line 1
  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(", "));
CallStoreChangeResultBlue line: runs next. 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 step-through follows one change from start to end:

  1. dispatch(action) hands the action to the store.
  2. The reducer gives back the next state.
  3. The store keeps that next state in place of the old one.
  4. The store calls each subscriber, so each part of the page can show the new state.
Step 1, an action card that says cart/add. An arrow to step 2, a cartReducer function card. An arrow to step 3, a new state cart that holds items: [tea]. An arrow down to step 4, a console window that shows cart/add: 1.1. actioncart/add2. reducercartReducer3. new stateitems: [tea]state4. subscriber runsConsolecart/add: 1
dispatch hands the action to the reducer. The reducer returns the next state, the store keeps it, and then the subscriber runs and prints cart/add: 1.

Unsubscribing matters too. When a part of the page goes away, it should stop listening. Line 39 does that. The second dispatch on line 40 still changes the store, but nothing prints for it.

This small store trusts your code

This store does not lock its state. getState() gives back the very object the store holds. So other code could change it directly. The rule is: only read what getState() gives you, and ask for changes with dispatch. JavaScript does not check this rule for you.

The same idea as Redux, in plain JavaScript

Redux is a popular state library that made this shape well known. You don't need it to use the idea: one state value, plain action objects, reducers, and subscribers. Libraries add extra tools on top, such as browser tools that show every action.

Reducers and actions

RULES FOR CHANGES

A reducer has this shape: (state, action) => nextState. It takes the current state and an action. It gives back the next state. A reducer must be a pure function. That means two rules:

  • The same state and the same action always give the same result.
  • It changes nothing outside itself. It doesn't fetch data, write to web storage, read the clock, or change the state it was given.

The pure functions lesson explains these rules in more detail.

The reducer rule

Same state and same action in, same next state out, and nothing else touched. Work like fetching data or saving to storage happens before or after dispatch, never inside the reducer.

An action asks for a change; the reducer gives back the next statePop out in the code editor (opens in a new tab)JavaScript
const addItem = (item) => ({ type: "cart/add", item });
function reducer(state, action) {
  if (action.type === "cart/add") {
    return { ...state, items: [...state.items, action.item] };
  }
  return state;
}

const before = { items: [] };
const after = reducer(before, addItem("tea"));
console.log(after.items.join(", "));
console.log(reducer(after, { type: "cart/unknown" }) === after);

Line 1 is an action creator: a small function that builds an action object for you. addItem("tea") gives back { type: "cart/add", item: "tea" }.

  • Lines 3 and 4 handle the add action. They give back a new state object with the item added.
  • Line 6 handles every other action. It gives back the same state, unchanged.
  • Line 11 prints tea: the added item.
  • Line 12 sends an action the reducer doesn't know. It prints true, because the reducer gave back the very same object. That tells the store nothing changed.

A real app has more than one area of state. You can give each area its own reducer. Each area is called a slice. For example:

  • A cart reducer changes only the cart's items.
  • A UI reducer changes only whether the cart panel is open.
  • A root reducer calls both and puts their results together.
Two slice reducers joined by a root reducerPop 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 are action creators. Line 4 starts the cart reducer, and line 11 starts the UI reducer. Lines 18 to 28 build the root reducer:

  • Line 23 calls each slice reducer with its own part of the state.
  • Line 24 notes whether any slice gave back a new object.
  • Line 26 gives back the new root object only if something changed. Otherwise it gives back the old one.

Lines 32 and 33 send two actions. The code prints 1 (one cart item) and true (the cart panel is open).

Copy instead of changing in place

SAME OBJECT OR NEW?

A variable that holds an object really holds the object's address: where the object lives in memory. Two variables can hold the address of the same object. The objects and references lesson explains this.

State management uses this on purpose. A reducer never changes the old object. Instead it makes a new object with the change in it. This is called an immutable update. "Immutable" means "never changed". Changing an object in place is called mutating it.

Why bother? Because then one quick check tells you if anything changed: prevState !== nextState. A new object means a change. The same object means no change. Old states also stay as they were, so you can keep them for an undo button.

You only copy the parts that change. These tools make copies:

  • Object spread, { ...obj }, from the spread and rest lesson.
  • Array spread, [...arr], and the array methods map and filter.
  • toSorted() and with(index, value). They give back a new array and leave the old one alone.
Change three parts of a board by copyingPop 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 starts a new board. Line 11 copies everything from the old board into it. Then:

  • Line 12 makes a new user with a new badges array that adds "reviewer".
  • Lines 13 to 15 make a new tasks array. Only the "draft" task is replaced, by a copy with done: true.
  • Line 16 swaps "medium" for "normal" with with, then sorts the result.

Lines 19 to 22 print false, true, false, and high, low, normal. The old task is still not done, and the old board is untouched. For more ways to copy, see immutability.

Copy every level down to the changePop out in the code editor (opens in a new tab)JavaScript
const state = {
  profile: { name: "Asha" },
  settings: { theme: "light" },
};
const next = {
  ...state,
  profile: { ...state.profile, name: "Mina" },
};

console.log(state.profile.name);
console.log(next.profile.name);
console.log(state.profile === next.profile);
console.log(state.settings === next.settings);

Lines 5 to 7 copy two objects: the outer state and the profile inside it. The name changes only in the copy. Line 10 prints Asha and line 11 prints Mina.

Line 12 prints false: the two profiles are different objects. Line 13 prints true: settings did not change, so both states share it. A spread only copies one level. If you spread only the outer object, both states would share one profile. Changing the name would then change the old state too.

The bug: the cart changed, but the state object did not
Step 0 of 6Ready
Your turn: follow the blue line

This reducer changes the old state instead of making a new one. Step through it and watch the subscriber miss a real change, because the state object stays the same.

Running in
  1. script
Next: line 31
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);
CallStoreChangeResultBlue line: runs next. 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 step-through above shows the bug slowly. On line 25, push adds the item to the old array. The cart really changes. But the reducer gives back the same state object. So the store's check finds the same object, decides nothing changed, and skips the subscriber. The page would keep showing the old cart.

Left: state.items.push puts tea into the same cart, so the store sees the same cart and renders stays 0. Right: a spread makes a new cart called next that holds tea, so the subscriber runs.state.items.push(...)id: "tea"statesame cart as beforerenders: 0{ ...state, items: [...] }id: "tea"nexta new cartsubscriber runs
Left: push changes the old cart, so the store thinks nothing changed and skips the subscriber. Right: the spread makes a new cart, so the store sees the change.
structuredClone copies everything, which is often too muchPop 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(value) is a built-in function that makes a deep copy: it copies the object and every object inside it. Line 6 copies the template. Line 7 changes the copy's name. Line 9 prints Asha, so the original did not change. Line 10 prints Mina. Line 11 shows the date was copied as a real Date.

It is handy, but it is not how reducers usually copy:

  • It throws an error for functions and page elements.
  • Copying the whole state is much more work than copying the one part that changed.
  • Parts that did not change become new objects too, so the quick "same object?" check stops working for them.
Write it like a change, get a copy: the idea behind ImmerJavaScript
// 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 is a library with a produce function. You write code that seems to change a draft, a stand-in for the state. Immer watches those changes and builds a new state from them. The old state is never touched. Immer is not installed here, so this block only shows the idea and does not run.

State machines block steps that make no sense

FIXED STAGES

Reducers are good for changing data. Some state is different: it moves through a few named stages. A request to a server, for example, is always in one of four stages:

  • idle: nothing has happened yet.
  • loading: the request was sent, and we are waiting.
  • success: the answer came back.
  • error: something went wrong.

It can never be loading and success at once. It also should not send a second request while the first one is still loading.

A state machine writes these rules down. It has a list of stages, called states. Things that happen, like SUBMIT, are called events. A transition table says which event moves which state to which next state. Any event not in the table for the current state is ignored.

Three state cards: idle, loading, and success. SUBMIT moves idle to loading. A second SUBMIT loops back to loading, so the machine stays in loading. RESOLVE moves loading to success.idleSUBMITloadingRESOLVEsuccessSUBMIT againstays loading
Only the events listed for the current state can move it. A second SUBMIT while loading is not listed, so the machine stays in loading.
A state machine: allowed and ignored events
Step 0 of 8Ready
Your turn: follow the blue line

A state machine has a list of states, events, and the moves between them. Step through an allowed event, an ignored one, and another allowed one.

Running in
  1. script
Next: line 1
  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;}
CallStoreChangeResultBlue line: runs next. 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 code that runs the machine is small. Line 9 looks up the event in the table, under the current state. The ?. there means "if this part is missing, give undefined instead of an error". If nothing is found, line 10 gives back the current state unchanged. That one line is what blocks the second SUBMIT.

Bigger apps sometimes need more: states inside states, or checks before a move. Libraries such as XState add these. But the core idea is this small table and lookup.

Where each piece of state should live

PLAYGROUND

The first question is not "Which library should I use?". It is "Who owns this value?". Here are the usual answers:

  • A value only one part of the page uses, like "is this tooltip showing?", stays in that part. This is local UI state.
  • Data several parts of the app read, like the cart, goes in one shared place. This is shared app state. It does not mean a variable on window.
  • Data fetched from a server belongs to the server. Your app keeps a copy, called a cache, with rules for when to fetch it again. This is server cache state.
  • Choices someone might share or come back to, like the sort order or page number, go in the web address. This is URL state. The URL objects lesson covers how to read and change a URL.
  • A form the user hasn't sent yet stays with the form.
Local UI, shared app, server cache, or URL?
  • isHelpPopoverOpen inside one button
  • Cart items shown in the header and on the checkout page
  • A product list fetched from the server, with a loading flag and an age
  • ?sort=price&page=2 in the address bar
  • An email address typed into a sign-up form, not sent yet
  • The signed-in user's name, shown in many places
  • The saved answer from /api/profile, with the time it was fetched
  • Search words that must survive a reload and work in a shared link
Try it yourself
0 of 8 correct

Put each card with its smallest owner that works. If someone should be able to share it, think URL. If the server owns it, think server cache.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
The URL holds a page number someone can sharePop out in the code editor (opens in a new tab)JavaScript
const url = new URL("https://shop.example/products?sort=price&page=2");
const page = Number(url.searchParams.get("page"));
url.searchParams.set("page", String(page + 1));

console.log(page);
console.log(url.search);

Line 1 makes a URL object from a product page address. Line 2 reads the page number, 2. Line 3 sets it to the next page. The code prints 2 and ?sort=price&page=3.

In a real app, the page should also follow the address. When the user presses Back or Forward, the page should show the page number in the address.

The playground below is a small todo list run by a reducer. Each button sends an action. The reducer gives back a new state, and the playground keeps every state in a list. The slider lets you go back to an older state. This is called time travel. It works because old states were never changed.

A todo store you can rewind
The todo reducer this playground runsPop 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 statestate 0
Filterall
Open count1
  • Draft reducer open
Actions sent
  1. @@init
Try it yourself

This is the starting state. Press the buttons to send actions. Then drag the slider back to see older states.

One reducer, a list of every action, and a list of every state. The slider only reads old states. It never changes them.

Try it: add a todo, mark one done, then drag the slider back. The older state still shows the todo as open, because the reducer made a new state instead of changing the old one.

Save private settings in storage and shareable choices in the URLPop 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);

Saving state so it survives a page reload is called persistence. It is a place to save a copy, not a second owner.

  • Lines 1 and 2 save private settings, like the theme, with localStorage.setItem. JSON.stringify turns the object into text, because storage only holds text.
  • Line 4 reads the text back and turns it into an object again with JSON.parse.
  • Lines 5 and 6 put the page number in a URL, so someone can reload, bookmark, or share it.
  • Lines 8 and 9 print dark and /products?sort=price&page=2.

The code runner on this page can't use storage. Try this code in the console of a real web page.

Where libraries fit

Libraries give you ready-made tools. You still need to decide who owns each value. Some common ones:

  • Redux Toolkit: a store with reducers, a record of every action, and Immer built in.
  • Zustand: small stores with very little setup. A part of the page can listen to just the values it uses.
  • Pinia: stores made for Vue apps.
  • Signals: single values that tell their readers when they change. The observer pattern lesson covers them.
  • XState: state machines with states, events, and checks, for flows where the order of steps matters.

Common misconceptions

  • "All state belongs in one shared store." A toggle or an unsent form that one part of the page uses should stay with that part.
  • "Storing the count makes the page faster." Store the items and work out the count. Only store it if you measure and find the work is really slow.
  • "A reducer can change the old state if the page still looks right." Then the "same object?" check fails. Subscribers get skipped, and undo shows the wrong data.
  • "State machines are only for huge apps." They help in any small flow with steps that must not happen, like sending a form twice.
  • "Saving to localStorage is state management." Storage only keeps a copy. After the page loads it back, the app still needs one owner and one way to change it.
  • "Signals mean I don't have to choose an owner." Signals change how readers are told about changes. You still decide what is local, shared, server cache, or URL state.

The table below compares four tools. Read each row as: "this tool is good for this, helps in this way, and needs care here."

Four ways to hold state, compared
ToolGood forHow it helpsWhat to watch for
A plain variableOne run of one function.It is quick and simple.It is gone when the function ends. Other parts can't read it.
One shared storeData that several parts of the app read.One way to change it, subscribers, a record of actions, and undo.It fills up with clutter if every small toggle goes in it.
A state machineFlows with a few named stages.It ignores steps that must not happen, like sending twice while loading.It is more setup than a simple counter or an open/closed toggle needs.
SignalsSingle values that many readers watch.A change tells only the readers of that value.Each library has its own rules for how readers are tracked.

Some nearby ideas have their own lessons. The observer pattern lesson covers subscribers and signals. The app architecture lesson covers how to split an app into parts. Structural and behavioral patterns covers commands and undo.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict two dispatches

Read the store code. 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 case. Write a return that gives back a new object and leaves the old state unchanged.

    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 code shows that state and next are the same object?

      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 · ChallengeFollow a form machine

        This form machine stops a form from being sent twice. After two SUBMIT events, which state is it 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 a stored count

          A teammate wants to store an itemCount value next to cart, and change it by hand. What should you store for the count 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 line leaves the original items array unchanged?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does this state machine print after the 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, so you should usually work it out instead of storing it?

              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 do undo and time travel need from reducers?

              Choose an answer to see the explanation.

            Key takeaways

            • State is data that changes while the app runs and changes what it shows or does. Not all of it belongs in one shared place.
            • A single source of truth means one owner. Nobody keeps a second copy that could disagree.
            • A reducer takes the current state and an action, and gives back the next state. It touches nothing else.
            • Copying instead of changing in place makes the "same object?" check work, and keeps old states for undo.
            • A state machine lists its stages and which events move between them. Any other event is ignored.
            • Use localStorage to save private settings. Use the URL for choices someone might share.

            Remember the one-liner.
            Give each changing value one owner, work out everything else from it, and change it only in one clear way.

            Up next: Structuring an application. It shows how these state owners fit into the parts of a whole app.

            CompleteFrontend Clear concepts. Working examples.