Skip to content

Manage and render interface state

You will build a task tracker that stores task records in one JavaScript state object and renders the task list and completion summary from that state.

An interface becomes difficult to maintain when the program stores one version of a value in JavaScript and a different version in the DOM. A state-and-render pattern gives the program one data source. Event handlers update that state, and render functions make the interface match it.

What you will practice

  • Represent related task records with an array of objects inside one state object.
  • Explain how a const object binding can keep one identity while its properties change.
  • Render DOM elements from state with a loop and document.createElement.
  • Handle form and checkbox events by updating state before rendering the result.
  • Calculate derived interface data instead of storing a second copy in state.
  • Preserve keyboard focus when a state change replaces DOM elements.
  • Trace the cycle from event to state update to rendered interface.
  • New: Interface state, arrays, objects, properties, array methods, for...of, document.createElement, append, replaceChildren, derived data, and focus restoration after rendering.
  • Reused: The practice workspace, external JavaScript, form events, event listeners, conditions, functions, template literals, DOM selection, input validation, textContent, accessible labels, live status text, and Console debugging.

Starting point

Before you start

  • The completed web-interaction-lessons folder from the DOM events lesson.
  • index.html loads app.js with defer.
  • You can select elements, prevent a form reload, read an input value, and update textContent.
  • VS Code and a current version of Edge, Chrome, or Firefox.
Current state
The form can show one calculated project-status string, but the program does not store or render a collection of records.
First action
Keep a copy of the completed DOM-events version, then replace the main element in index.html with the task-tracker markup in the next section.
First checkpoint
The browser shows a labeled task input, an Add task button, an empty task-list region, and an empty completion summary.
Help trigger
Use the recovery note or ask for help if the initial records do not render, adding one task creates several rows, a checkbox changes the wrong record, or keyboard focus disappears after a checkbox change.

You have completed the lesson when:

  • one state object contains nextTaskId and a tasks array;
  • every task record has a numeric id, string name, and boolean completed property;
  • renderTasks replaces the visible list with elements created from the current state;
  • every rendered checkbox has a unique id and a connected text label;
  • the completion count is derived with filter instead of stored as a second state value;
  • submitting an empty task shows Enter a task description., applies aria-invalid="true", and returns focus to the input;
  • submitting Run narrow viewport check adds one record, renders one new list item, clears the field, and updates the summary;
  • changing a checkbox updates the matching task record, re-renders the summary, and restores focus to that task’s checkbox;
  • user-provided task text is assigned with textContent and is not parsed as HTML;
  • reloading the page restores the two initial records because persistence is outside this lesson; and
  • the final version runs without a JavaScript error in the Console.

Replace the existing main element in index.html with this markup. Keep the document structure and deferred script element.

web-interaction-lessons/index.html — main element
<main>
<h1>Project task tracker</h1>
<form id="task-form">
<div>
<label for="task-name">Task description</label>
<input
id="task-name"
name="taskName"
type="text"
aria-describedby="task-error"
>
<p id="task-error" role="status"></p>
</div>
<button type="submit">Add task</button>
</form>
<h2>Required tasks</h2>
<ul id="task-list"></ul>
<p id="task-summary" role="status"></p>
</main>

The HTML defines stable interface regions. JavaScript will create the individual list items because their number and completion values come from state.

  • task-error reports a form-validation result close to the input.
  • task-list receives the rendered task records.
  • task-summary reports the derived completion count.
  • Both status regions begin empty. JavaScript supplies their current text.

Save index.html and reload the page. An empty list and summary are expected because the old app.js still targets the previous form. The previous lesson’s missing-elements error can appear in the Console until you replace app.js in the next section.

Replace all code in app.js with this initial state:

web-interaction-lessons/app.js
"use strict";
const state = {
nextTaskId: 3,
tasks: [
{
id: 1,
name: "Write semantic HTML",
completed: true,
},
{
id: 2,
name: "Add keyboard checks",
completed: false,
},
],
};

An object groups values as named properties. state.nextTaskId and state.tasks use dot notation to read properties from the state object.

An array stores an ordered collection of values. Square brackets create the tasks array. Each value in this array is a task object with the same three properties:

Property Type Purpose
id Number Gives one record a stable identity
name String Supplies the visible task description
completed Boolean Records whether the task is complete

nextTaskId starts at 3 because the two initial records already use 1 and 2.

const keeps the binding, not every nested value

Section titled “const keeps the binding, not every nested value”

state uses const, so the program cannot assign a different object to the state identifier. The object’s properties and the contents of its tasks array can still change.

Allowed property changes
state.nextTaskId += 1;
state.tasks.push({ id: 3, name: "Test the form", completed: false });
Not allowed for a const binding
state = { nextTaskId: 1, tasks: [] };

The required program keeps one state object binding and changes its task data through explicit event handlers.

Add these selections after the state declaration:

Select the task interface
const taskForm = document.querySelector("#task-form");
const taskNameInput = document.querySelector("#task-name");
const taskError = document.querySelector("#task-error");
const taskList = document.querySelector("#task-list");
const taskSummary = document.querySelector("#task-summary");
if (
!taskForm
|| !taskNameInput
|| !taskError
|| !taskList
|| !taskSummary
) {
throw new Error("Required task tracker elements are missing.");
}

The guard formats one condition across several lines. Every line after the first begins with ||, so one missing element makes the complete condition true.

Test one incorrect selector, confirm the specific Console error, and restore the selector before you continue.

A render function reads the current state and updates the interface to match it. Add this function after the missing-elements guard:

Render the task list and summary
function renderTasks(focusTaskId) {
taskList.replaceChildren();
for (const task of state.tasks) {
const listItem = document.createElement("li");
const checkbox = document.createElement("input");
const label = document.createElement("label");
checkbox.type = "checkbox";
checkbox.id = `task-${task.id}`;
checkbox.checked = task.completed;
label.htmlFor = checkbox.id;
label.textContent = task.name;
checkbox.addEventListener("change", () => {
task.completed = checkbox.checked;
renderTasks(task.id);
});
listItem.append(checkbox, label);
taskList.append(listItem);
if (task.id === focusTaskId) {
checkbox.focus();
}
}
const completedCount = state.tasks.filter(
(task) => task.completed,
).length;
taskSummary.textContent = `${completedCount} of ${state.tasks.length} tasks complete.`;
}

Call the function once at the end of app.js:

Create the initial interface
renderTasks();

Save and reload. The page now shows two labeled checkboxes. The first is checked, and the summary says 1 of 2 tasks complete.

  1. replaceChildren() removes the list items from the previous render.
  2. for...of visits each task object in state.tasks.
  3. createElement creates one list item, checkbox, and label for that task.
  4. DOM properties connect the checkbox state, unique ID, label, and visible text to the record.
  5. The change listener updates the record and renders again.
  6. append builds the list-item structure and adds it to the list.
  7. The focus condition restores focus after a checkbox-triggered re-render.
  8. filter creates an array containing only completed records. .length gives the derived count.
  9. textContent writes the current completed and total counts to the summary.

The program does not store completedCount in state. That value can always be calculated from state.tasks, so storing another copy would create a synchronization risk.

Changing a checkbox calls renderTasks(task.id). The function removes the old checkbox nodes and creates new ones. Without focus restoration, a keyboard user could lose their current position.

The task ID identifies the new checkbox that represents the same record. After appending that checkbox, the function calls focus() on it. The initial renderTasks() call supplies no ID, so no checkbox receives forced focus during page load.

  1. Use Tab to focus Add keyboard checks.
  2. Press Space to check it.
  3. Confirm that the summary changes to 2 of 2 tasks complete.
  4. Confirm that focus remains on the Add keyboard checks checkbox after the render.
  5. Press Space again. The summary returns to 1 of 2 tasks complete.

Checkpoint: The interface renders from state

What now works
Two state records render as connected checkbox-label pairs, the summary derives the correct count, and checkbox changes update state without losing keyboard focus.
Files changed
index.html, app.js
What remains
Add new task records through the form and run the complete state-cycle checks.
Next action
Keep the second task incomplete and add the form submit handler before the initial renderTasks call.
If it does not work
Log state.tasks before and after one checkbox change. Confirm that the listener changes task.completed and passes that task's id to renderTasks.

Add this handler before the final renderTasks() call:

Add a task record
function handleTaskSubmit(event) {
event.preventDefault();
const taskName = taskNameInput.value.trim();
if (taskName === "") {
taskNameInput.setAttribute("aria-invalid", "true");
taskError.textContent = "Enter a task description.";
taskNameInput.focus();
return;
}
state.tasks.push({
id: state.nextTaskId,
name: taskName,
completed: false,
});
state.nextTaskId += 1;
taskNameInput.removeAttribute("aria-invalid");
taskError.textContent = "";
taskForm.reset();
renderTasks();
taskNameInput.focus();
}
taskForm.addEventListener("submit", handleTaskSubmit);

The successful path follows the required cycle:

  1. Read and validate the input.
  2. Use push to add one task object to state.tasks.
  3. Increment state.nextTaskId so the next record receives a different ID.
  4. Clear the validation state and reset the form.
  5. Render from the changed state.
  6. Return focus to the input so another task can be added.

The handler does not create a list item directly. It changes the state and delegates DOM creation to renderTasks. This keeps one function responsible for the visible list structure.

  1. Submit the empty field. Confirm Enter a task description., aria-invalid="true", and input focus.
  2. Enter only spaces and submit. Confirm the same result.
  3. Enter Run narrow viewport check and submit.
  4. Confirm that the input clears, the error clears, and a third unchecked task appears.
  5. Confirm that the summary says 1 of 3 tasks complete.
  6. Check the new task with the keyboard. Confirm 2 of 3 tasks complete. and stable checkbox focus.
  7. Add <strong>Review</strong> and confirm that the literal tag text appears instead of a strong element.

The last test works because the renderer assigns task.name through label.textContent. It does not parse user input as HTML.

Checkpoint: Events update state before rendering

What now works
A valid submission adds one task record and one rendered row, invalid input stays out of state, completion changes update the matching record, and all summaries match the current array.
Files changed
index.html, app.js
What remains
Trace the complete architecture and verify the required reset boundary.
Next action
Reload the page, confirm that only the two initial records return, then complete the self-check.
If it does not work
Log state.tasks.length before and after one submit. If one submit adds several records, check for duplicate submit listeners or a listener registered inside renderTasks.

The interface uses one directional cycle:

initial state
-> renderTasks()
-> DOM interface
user event
-> event handler reads input
-> event handler changes state
-> renderTasks() reads current state
-> DOM interface matches state

The DOM still contains temporary interface state such as the current input value and focused element. The task records and completion values come from the JavaScript state object.

The state exists only in the current page’s memory. Reloading the document creates a new JavaScript environment and runs the initial state declaration again. The required lesson does not use localStorage, a database, or a server, so added tasks do not persist across reloads.

Self-check

Complete these checks against the required result.

  1. Reload the page and confirm that the two initial records render with the summary 1 of 2 tasks complete.
  2. Point to the array, one task object, and each property type in the initial state.
  3. Explain why state can use const while state.nextTaskId and state.tasks still change.
  4. Submit empty and whitespace-only values and confirm that state.tasks.length does not change.
  5. Add Run narrow viewport check and confirm one new record, one new list item, and the summary 1 of 3 tasks complete.
  6. Change the new checkbox with the keyboard and confirm that the matching record, summary, and focus all update correctly.
  7. Add <strong>Review</strong> and confirm that the label contains literal text rather than a new strong element.
  8. Explain why completedCount is derived inside renderTasks instead of stored in state.
  9. Reload and confirm that only the two initial tasks return, then confirm that no red JavaScript error remains in the Console.

Reference patterns for lookup, absence, and grids

Section titled “Reference patterns for lookup, absence, and grids”

The required task-tracker result is complete before this section. Read the relevant pattern when a later project needs to represent no active record, match an input value to one record, use a rectangular coordinate system, or create changing runtime data from a fixed definition.

Use null when a property must exist but deliberately has no active record yet:

One state property with no active record
const interfaceState = {
selectedTask: null,
};

null is one JavaScript value. In this contract, it means no active task record. Test that meaning explicitly:

Check the absence contract
if (interfaceState.selectedTask === null) {
console.log("No active task.");
}

Use different initial values for different meanings. An empty string means that a required text value is absent. An empty array means that a collection currently contains no records. null means that one expected record is not active. Do not switch among null, undefined, an empty string, and an empty object for the same property.

The visible text in a choice can change. A stable identity lets the program find the same record without depending on that text.

Fixed project-area records
const projectAreas = [
{ id: "content", name: "Content" },
{ id: "development", name: "Development" },
{ id: "quality", name: "Quality" },
];

Use a function to compare the selected string with each record’s stable id:

Return one matching record or null
function findProjectArea(areaId) {
for (const area of projectAreas) {
if (area.id === areaId) {
return area;
}
}
return null;
}

The first matching record ends the function. If no record matches, the function returns null. The caller must check that result before reading record properties. This turns a missing or invalid choice into a defined branch instead of a later property error.

Check: Call the function with each documented ID and one unknown ID. Confirm that each known ID returns its record and the unknown ID returns null.

A rectangular grid can use one ordered array of cell records. Each record identifies one position and the content at that position:

A small workspace grid
const workspaceCells = [
{ row: 0, column: 0, kind: "entrance" },
{ row: 0, column: 1, kind: "desk" },
{ row: 1, column: 0, kind: "path" },
{ row: 1, column: 1, kind: "storage" },
];

This example uses row-major order: all cells in row 0 appear before all cells in row 1. Inside each row, column numbers increase from left to right. Stable order helps a render loop produce the same visual order each time.

Use both coordinate properties to find one destination cell:

Find one cell by row and column
function findWorkspaceCell(row, column) {
for (const cell of workspaceCells) {
if (cell.row === row && cell.column === column) {
return cell;
}
}
return null;
}

The current coordinate remains unchanged while the program calculates a proposed destination. The program accepts the proposal only after the lookup returns an allowed cell. A null result means that the coordinate does not exist in this grid. A returned cell can still have a kind that the current action rejects.

Check: Look up the four documented coordinates and one outside coordinate. Confirm that each documented coordinate returns one record, the outside coordinate returns null, and the array order remains unchanged.

Keep fixed definitions separate from changing records

Section titled “Keep fixed definitions separate from changing records”

A fixed definition describes reusable starting facts. A runtime record describes one current use of those facts. Do not put a changing value into the fixed definition and then reuse the same object as current state.

Create changing data from one fixed definition
const taskDefinitions = [
{ id: "review", name: "Review the page", startingMinutes: 30 },
];
const selectedDefinition = taskDefinitions[0];
const activeTask = {
id: selectedDefinition.id,
name: selectedDefinition.name,
minutesRemaining: selectedDefinition.startingMinutes,
};

Changing activeTask.minutesRemaining must not change selectedDefinition.startingMinutes. A later runtime record made from the same definition must receive the documented starting value again.

Check: Change the runtime value, then inspect both records. Create a second runtime record and confirm that it begins with the fixed starting value.

Symptom Likely cause Focused check
Initial list stays empty The initial renderTasks() call is missing or occurs before required setup Confirm one call appears after listener registration
A submit changes the array but not the page The handler does not render after push Confirm renderTasks() follows the state update
One submit adds several identical tasks The submit listener is registered during each render Register the form listener once, outside renderTasks
A checkbox changes the wrong task IDs are repeated or the listener closes over the wrong record Inspect each task id and the task used inside its listener
Focus moves to the page start The checkbox render does not pass or match focusTaskId Confirm renderTasks(task.id) and the focus condition
The summary drifts from the checkboxes The summary uses stored or manually incremented data Derive it from state.tasks.filter(...) on every render
Added HTML-looking text creates elements The renderer uses innerHTML with input data Assign the task name with textContent
A missing active record causes a property error The code reads properties before checking the absence value Test the documented null branch before reading the record
A lookup returns the wrong record or no record The selected value and record identity use different values or types Log both identities and compare them with strict equality
A grid move reaches the wrong cell The lookup compares only one coordinate or the row and column are reversed Log the proposed row and column and compare both with one cell record
A later runtime record starts with an old changed value The program reused a changing record as a fixed definition Inspect the definition after one update and create a separate runtime record

Change one part of the event-state-render cycle at a time. Inspect the state before rewriting the renderer.

The required lesson is complete when one state object owns the task records, every event updates that state before rendering, derived summaries stay accurate, keyboard focus survives checkbox renders, and reload returns the documented initial state.

Continue to Practice: Build an interactive web application to apply the JavaScript, DOM-event, and state-render patterns to an independent product brief.

If you stop here, leave yourself this resume note: The task tracker now follows event → state update → render. Next, open the web-interaction assignment and choose the smallest required application behavior that proves the same cycle.