Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 3

State

How to decide what an interface remembers, where that memory lives, and how to keep the page in step with it, ending with a task board that adds, completes, filters, counts, and clears tasks.

Someone is planning their week in a small task list. They add a few tasks, check off the ones they’ve finished, and switch to “Active” to see only what’s left. The count under the list should say exactly how many tasks remain, and when they clear the finished ones, those tasks should vanish from the list, the count, and every filter at once.

State is what the interface remembers. Interactive interfaces are easier to build when you think of them as a set of remembered values. Is the menu open? Which tab is selected? Which items are checked? Has this field been touched? Is the request still loading, and did it fail? Once you can name those values, the question of what should be on the screen has a definite answer.

The mental model

This chapter is about the first of the four questions: what state does this interface need to remember? The other three questions depend on it. Events change state, the screen shows state, and accessibility attributes like aria-expanded and aria-pressed are state made available to assistive technology.

The pattern used through the rest of the book is a loop with three steps:

  1. An event happens.
  2. The handler updates the state.
  3. A render step makes the page match the state.

The handler never edits the page directly. It changes the remembered values and asks the page to catch up. That sounds indirect at first, but it means there’s exactly one place to look when the page shows the wrong thing: the state, or the code that turns state into the page.

Choosing a shape for state

State is ordinary JavaScript values held in variables. The skill is picking the shape that matches the question the interface is asking.

Booleans for on and off

A toggle button asks one yes-or-no question, so a boolean answers it. You’ve already used two: isOpen for the shipping details in chapter 1, and isVisible for the password toggle in chapter 2.

let isMuted = false;

Strings and numbers for selected values

Tabs ask “which one?”, so a string naming the selected tab is a better fit than one boolean per tab:

let selectedTab = 'billing';

An accordion is a useful case to think through. If only one panel can be open at a time, a single string holding the open panel’s id describes it completely, and opening another panel automatically closes the first. If several panels can be open at once, you need a collection instead. The design decision and the data shape are the same decision.

Numbers fit values people adjust, like a quantity in a shopping cart. The rules about which numbers are allowed belong next to the update:

let quantity = 1;

function increase() {
  quantity = Math.min(quantity + 1, 10);
}

Arrays for collections

A multi-select checklist asks “which ones?”, and the answer is a list:

let selectedTopics = ['accessibility', 'performance'];

Objects for grouped state

As an interface grows, related values belong together. An object groups them under one name, so you can see the whole state of a feature at a glance:

const state = {
  selectedTopics: ['accessibility'],
  isSubmitting: false,
};

Declaring state with const means the variable always points at the same object. You can still change its properties, as in state.isSubmitting = true or state.selectedTopics = [].

Where state can live

State doesn’t have to live in a JavaScript variable. Each place has tradeoffs:

Where it livesExampleGood forWatch out for
The DOMcheckbox.checked, the hidden attributeSingle native controlsLost when markup is rebuilt, and hard to see all at once
A variable or objectconst state = { ... }Anything your script coordinatesLost when the page reloads
The URL?filter=activeViews people share or bookmark, and the Back buttonValues are strings. Covered in chapter 8
localStorageA saved theme preferenceSurviving a reload on this deviceStrings only, and it can throw. Covered in chapter 8
The serverSaved tasksSharing across devices and peopleSlow and can fail. Covered in chapter 7

Most interfaces use several of these at once. That’s fine, as long as you know which one is in charge of each value.

The source of truth

The source of truth for a value is the one place you treat as correct. Every other copy of that value is updated from it, never the other way around.

Trouble starts when the same fact is stored twice without a plan. Picture a selected item preview, where a list of products is shown beside a panel describing the selected one. If the script remembers the selected product in a variable and also marks the selected list item with a class, and also stores the product name in the preview heading, there are three records of one fact. A bug that updates two of them leaves the page contradicting itself, and the person sees one product highlighted while reading about another.

The fix is to choose one record, the variable, and treat the class and the heading as displays of it. When the selection changes, update the variable, then update both displays from it.

Ask “where is the source of truth?” for every value in every project. If the answer is “in a few places,” something will drift.

Derived state

Some values don’t need to be stored at all, because you can calculate them from other state whenever you need them. That is derived state.

In a shopping cart, the total is the quantity times the price. Storing the total in its own variable means remembering to update it whenever the quantity changes. Calculating it when you render means it can’t be wrong:

const price = 18;
let quantity = 2;

const total = price * quantity;  // calculated, not stored

A good rule is to store the smallest set of values that describes the situation, and derive everything else. Counts, totals, “is anything selected?”, and “is this list empty?” are almost always derived.

State transitions and request states

A state transition is a change from one value to another, and some transitions shouldn’t be possible. A step-by-step form shouldn’t jump from step 1 to step 4, and a quantity shouldn’t drop below 1. Keep those rules beside the code that changes the state, as increase() does above, so they’re enforced in one place.

The most common transition problem comes from describing a request with several booleans:

let isLoading = false;
let hasError = false;
let hasData = false;

Three booleans allow eight combinations, and most of them make no sense. Nothing stops isLoading and hasError from both being true, and then the page shows a spinner and an error message together. A single string with a fixed list of values makes the impossible combinations impossible to write:

let status = 'idle';  // 'idle' | 'loading' | 'success' | 'error'

The empty state usually isn’t a separate status. It’s derived: a successful request that returned no items. So a page that fetches data needs to plan for four things people can see: loading, success with results, success with nothing (empty), and error. Chapter 7 builds all four.

Keeping state and the DOM in sync

You can read state from the DOM. A checkbox already knows whether it’s checked, and reading checkbox.checked when you need it is fine for a single control. The problem comes when the page needs to combine several pieces of information, or when the markup is rebuilt and the elements holding the state are replaced.

This book keeps the state in JavaScript and writes it to the DOM. The DOM becomes a display of the state, and a render function is the code that does the writing. A render function sets every part of the page that depends on state, every time it runs. It doesn’t check what changed or update only one piece. That makes it slightly more work for the browser and much less work for you, because the page can only show what the state says.

The smallest working version

The pattern is a multi-select checklist with a count and a “Clear selection” button. The state is an array of selected values, and the count and the button are derived from it.

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Choose topics</title>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <fieldset data-topic-list>
      <legend>Topics to follow</legend>
      <label><input type="checkbox" value="accessibility"> Accessibility</label>
      <label><input type="checkbox" value="performance"> Performance</label>
      <label><input type="checkbox" value="typography"> Typography</label>
      <label><input type="checkbox" value="motion"> Motion</label>
    </fieldset>
    <p data-selection-count></p>
    <button type="button" data-clear-selection>Clear selection</button>
  </body>
</html>
// app.js
const topicList = document.querySelector('[data-topic-list]');
const checkboxes = topicList.querySelectorAll('input[type="checkbox"]');
const selectionCount = document.querySelector('[data-selection-count]');
const clearButton = document.querySelector('[data-clear-selection]');

const state = {
  selected: [],
};

function render() {
  for (const checkbox of checkboxes) {
    checkbox.checked = state.selected.includes(checkbox.value);
  }
  selectionCount.textContent = `${state.selected.length} of ${checkboxes.length} selected`;
  clearButton.disabled = state.selected.length === 0;
}

topicList.addEventListener('change', (event) => {
  const checkbox = event.target;

  if (checkbox.checked) {
    state.selected.push(checkbox.value);
  } else {
    state.selected = state.selected.filter((value) => value !== checkbox.value);
  }

  render();
});

clearButton.addEventListener('click', () => {
  state.selected = [];
  render();
});

render();

push adds a value to the end of an array. filter builds a new array containing only the items for which the function returns true, so here it keeps every value except the one that was unchecked. includes reports whether an array contains a value. Chapter 4 explains functions passed to other functions, and chapter 5 covers array methods in depth.

The checkboxes report what the person did, and the handler writes that into state.selected. Then render sets every checkbox, the count, and the button from the array. The “Clear selection” button doesn’t touch any checkbox directly. It empties the array, and render unchecks everything.

Accessibility check

Keyboard. Tab to each checkbox and press Space to toggle it, then Tab to “Clear selection” and press Enter. All four boxes clear and the count reads “0 of 4 selected.” Now look for the focus ring. It’s gone, and document.activeElement in the console shows <body>.

That happens because render disabled the button while it had focus. A disabled button can’t hold focus, so the browser moves focus to the page body. The next Tab usually continues from near the button, but in between nothing has focus, and a screen reader user may hear nothing at all, with no indication of what happened.

Screen reader. Check a box and listen. The screen reader announces the checkbox as checked, but the count below the list changes silently. A sighted user sees “2 of 4 selected” update. A screen reader user only finds out by moving to it.

The fix. Leave the button enabled. Clearing an empty selection does nothing harmful, so there’s no need to disable it. And make the count a status region, so its changes are announced politely after the current speech:

<p role="status" data-selection-count></p>
function render() {
  for (const checkbox of checkboxes) {
    checkbox.checked = state.selected.includes(checkbox.value);
  }
  selectionCount.textContent = `${state.selected.length} of ${checkboxes.length} selected`;
}

Disabling controls based on state is common, and chapter 2’s Save button did it on purpose with a visible explanation beside it. When a control can disable itself in response to its own activation, decide where focus should go first. Chapter 9 covers focus management in detail.

Debugging challenge: the count that’s wrong

Here is a version of the handlers with a small “optimization.” Instead of deriving the count from the array, it keeps a separate counter:

let selectedCount = 0;

topicList.addEventListener('change', (event) => {
  const checkbox = event.target;

  if (checkbox.checked) {
    state.selected.push(checkbox.value);
    selectedCount = selectedCount + 1;
  } else {
    state.selected = state.selected.filter((value) => value !== checkbox.value);
    selectedCount = selectedCount - 1;
  }

  render();
});

clearButton.addEventListener('click', () => {
  state.selected = [];
  render();
});

render now uses selectedCount in the text: `${selectedCount} of ${checkboxes.length} selected`.

The symptom. Check two topics. The count says “2 of 4 selected,” which is correct. Click “Clear selection.” Every box unchecks, but the count still says “2 of 4 selected.” Check one box and it says “3 of 4 selected.”

Finding it. The checkboxes are right and the count is wrong, so the two must be coming from different places. Add a log at the top of render:

console.log(state.selected, selectedCount);

Check two boxes, then clear. The console shows [] 2. The array is empty and the counter is 2. They are two records of the same fact, and the clear handler only updated one of them.

The fix. Delete selectedCount and derive the count from the array with state.selected.length, as the original did. Adding selectedCount = 0 to the clear handler would make this symptom go away, but the next feature that changes the selection would need to remember the counter too. Derived values can’t fall out of step, because nothing stores them.

This works because…

This works because the array state.selected is the only record of which topics are chosen. Checking or unchecking a box updates the array, and the Clear button empties it. After every change, render sets each checkbox and the count from the array, so the page always shows what the array says. The count is calculated from the array’s length each time instead of stored, so it can’t disagree with the checkboxes. The count is a status region, so screen reader users hear it change, and the Clear button stays enabled, so it never drops keyboard focus.

Project: task board lite

The project is a focused task list. The person can add tasks, mark them complete, filter by All, Active, or Completed, see how many tasks are left, and clear completed tasks. It isn’t a full kanban board, and it deliberately keeps everything in one script with a render that rebuilds the list from scratch every time.

Its state has exactly this shape:

const state = {
  tasks: [],      // each task is { id, label, completed }
  filter: 'all',  // 'all' | 'active' | 'completed'
};

When you finish, you should be able to explain the app in one sentence: “I have an array of task objects. Each task has an id, a label, and a completed value. When the user changes something, I update the array and re-render the list.” If you can say that and point to the code for each part, you understand the pattern that most interactive interfaces are built on, including the ones made with frameworks.

Everything else on the screen is derived: which tasks are visible, how many are left, how many are completed, which filter button is pressed, and which empty message to show.

Starter HTML

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Task board</title>
    <link rel="stylesheet" href="styles.css">
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <main class="board">
      <h1>Tasks</h1>
      <noscript><p>The task board needs JavaScript to run.</p></noscript>

      <form class="add-task" data-add-task-form novalidate>
        <label for="new-task">New task</label>
        <input id="new-task" name="label" autocomplete="off" aria-describedby="new-task-error" data-new-task-input>
        <button type="submit">Add task</button>
        <p id="new-task-error" class="field-error" data-new-task-error hidden></p>
      </form>

      <div class="filters" role="group" aria-label="Show tasks" data-filter-group>
        <button type="button" data-filter="all" aria-pressed="true">All</button>
        <button type="button" data-filter="active" aria-pressed="false">Active</button>
        <button type="button" data-filter="completed" aria-pressed="false">Completed</button>
      </div>

      <h2 tabindex="-1" data-list-heading>Your tasks</h2>
      <ul class="task-list" data-task-list></ul>
      <p data-empty-message hidden></p>

      <div class="board-footer">
        <p role="status" data-task-count></p>
        <button type="button" data-clear-completed>Clear completed</button>
      </div>
    </main>
  </body>
</html>

The styles.css file needs the [hidden] rule from chapter 1. The tabindex="-1" on the heading lets the script move focus to it without adding it to the Tab order. You’ll see why below.

The complete app.js

// app.js
const addForm = document.querySelector('[data-add-task-form]');
const newTaskInput = document.querySelector('[data-new-task-input]');
const newTaskError = document.querySelector('[data-new-task-error]');
const filterGroup = document.querySelector('[data-filter-group]');
const filterButtons = filterGroup.querySelectorAll('[data-filter]');
const listHeading = document.querySelector('[data-list-heading]');
const taskList = document.querySelector('[data-task-list]');
const emptyMessage = document.querySelector('[data-empty-message]');
const taskCount = document.querySelector('[data-task-count]');
const clearCompletedButton = document.querySelector('[data-clear-completed]');

const state = {
  tasks: [],
  filter: 'all',
};

function render() {
  // Work out which tasks the current filter shows.
  const visibleTasks = state.tasks.filter((task) => {
    if (state.filter === 'active') return !task.completed;
    if (state.filter === 'completed') return task.completed;
    return true;
  });

  // Rebuild the list from scratch.
  taskList.replaceChildren();

  for (const task of visibleTasks) {
    const item = document.createElement('li');
    item.className = 'task';
    item.dataset.taskId = task.id;

    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.id = `task-${task.id}`;
    checkbox.checked = task.completed;

    const label = document.createElement('label');
    label.htmlFor = checkbox.id;
    label.textContent = task.label;

    item.append(checkbox, label);
    taskList.append(item);
  }

  // Empty state: different messages for "no tasks" and "none match this filter".
  if (visibleTasks.length > 0) {
    emptyMessage.hidden = true;
  } else if (state.tasks.length === 0) {
    emptyMessage.hidden = false;
    emptyMessage.textContent = 'No tasks yet. Add one above.';
  } else {
    emptyMessage.hidden = false;
    emptyMessage.textContent = `No ${state.filter} tasks.`;
  }

  // Derived counts.
  const activeCount = state.tasks.filter((task) => !task.completed).length;
  const completedCount = state.tasks.length - activeCount;

  taskCount.textContent = activeCount === 1 ? '1 task left' : `${activeCount} tasks left`;
  clearCompletedButton.textContent = `Clear completed (${completedCount})`;

  // Mark the current filter.
  for (const button of filterButtons) {
    button.setAttribute('aria-pressed', String(button.dataset.filter === state.filter));
  }
}

addForm.addEventListener('submit', (event) => {
  event.preventDefault();

  const label = newTaskInput.value.trim();

  if (label === '') {
    newTaskError.textContent = 'Enter a task before adding it.';
    newTaskError.hidden = false;
    newTaskInput.setAttribute('aria-invalid', 'true');
    newTaskInput.focus();
    return;
  }

  newTaskError.textContent = '';
  newTaskError.hidden = true;
  newTaskInput.removeAttribute('aria-invalid');

  state.tasks.push({
    id: crypto.randomUUID(),
    label: label,
    completed: false,
  });

  newTaskInput.value = '';
  render();
});

taskList.addEventListener('change', (event) => {
  const item = event.target.closest('[data-task-id]');
  if (!item) return;

  const id = item.dataset.taskId;

  for (const task of state.tasks) {
    if (task.id === id) {
      task.completed = !task.completed;
    }
  }

  render();

  // The render replaced the checkbox that had focus, so put focus back.
  const checkbox = document.getElementById(`task-${id}`);
  if (checkbox) {
    checkbox.focus();
  } else {
    listHeading.focus();
  }
});

filterGroup.addEventListener('click', (event) => {
  const button = event.target.closest('[data-filter]');
  if (!button) return;

  state.filter = button.dataset.filter;
  render();
});

clearCompletedButton.addEventListener('click', () => {
  state.tasks = state.tasks.filter((task) => !task.completed);
  render();
});

render();

How it fits together

Rendering. render is the only code that writes the list, the empty message, the count, and the filter buttons. It rebuilds the whole list with replaceChildren(), which removes every child when called with no arguments, then creates each item with createElement and sets the label with textContent, so a task called <b>urgent</b> shows those characters instead of bold text. Rebuilding everything is simple and fast enough for a list this size. Chapter 5 covers building lists more carefully, including <template> and stable keys.

Adding. The form’s submit event covers both the button and Enter in the field. Each new task gets an id from crypto.randomUUID(), which returns a random string like "3b241101-e2bb-4255-8caf-4136c566a962". The id lets the script find the right task later, even if two tasks have the same label. crypto.randomUUID() only works in a secure context, which includes pages served from http://localhost, so it works with the local server from the introduction.

Completing. One delegated change listener on the list handles every checkbox, including ones created after the listener was attached. It reads the task’s id from the list item’s data-task-id, flips that task’s completed value, and renders.

Filtering. The filter buttons only change state.filter. render derives the visible tasks from it and sets aria-pressed so screen readers announce which filter is on.

Clearing. filter builds a new array without the completed tasks, and render updates the list, the count, and the button label from it. The button stays enabled, so it never drops focus the way the disabled button did in the smallest working version, and its label shows how many tasks it will clear.

The focus problem a rebuild creates

Rebuilding the list has a cost. When someone checks a task with the keyboard, the checkbox they’re on is removed and replaced with a new one by render. The browser can’t keep focus on an element that’s no longer in the page, so focus is lost, and the next Tab starts from somewhere unexpected.

The change handler fixes this after rendering. It looks for the new checkbox with the same id and focuses it. If the task is no longer visible, for example because the person completed it while the Active filter was on, it moves focus to the list heading instead, which is the nearest sensible place. The heading’s tabindex="-1" is what allows a script to focus it. Chapter 9 covers focus management in more depth, and chapter 5 shows rendering approaches that avoid replacing the focused element in the first place.

Done means

  • The state matches the shape above exactly, and every task has an id from crypto.randomUUID().
  • The list, the empty message, the count, and the filter buttons are only written inside render.
  • The count, the Clear completed label, the empty message, and the pressed filter are all derived from state and never stored separately.
  • Task labels are set with textContent. A task named <img src=x> appears as text.
  • Submitting an empty task shows an error connected to the field, and adding a valid task clears it.
  • Every control works with the keyboard: Enter adds a task, Space toggles a checkbox, and Enter or Space activates a filter.
  • After toggling a task with the keyboard, focus is on that task’s checkbox, or on the list heading if the task left the current filter.
  • A screen reader announces each task as a checkbox with its label and checked state, announces which filter button is pressed, and announces the count when it changes.
  • You’ve decided what should happen when someone adds a task while the Completed filter is on, since the new task won’t be visible, and written down why.
  • You’ve written the one-sentence explanation from the start of this project, plus a “This works because…” paragraph that covers the render step and the focus fix.

In chapter 4, you’ll take this script apart into small, named functions, without changing what the task board does.

Found an error? Report it.