Manage and render interface state
Outcome
Section titled “Outcome”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.
Why this matters
Section titled “Why this matters”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.
What is new and what is reused
Section titled “What is new and what is reused”- 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.
Required result
Section titled “Required result”You have completed the lesson when:
- one
stateobject containsnextTaskIdand atasksarray; - every task record has a numeric
id, stringname, and booleancompletedproperty; renderTasksreplaces the visible list with elements created from the current state;- every rendered checkbox has a unique
idand a connected text label; - the completion count is derived with
filterinstead 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
textContentand 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 single-status interface
Section titled “Replace the single-status interface”Replace the existing main element in index.html with this markup. Keep the document structure and deferred script 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-errorreports a form-validation result close to the input.task-listreceives the rendered task records.task-summaryreports 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.
Represent the interface state
Section titled “Represent the interface state”Replace all code in app.js with this initial state:
"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.
state.nextTaskId += 1;state.tasks.push({ id: 3, name: "Test the form", completed: false });state = { nextTaskId: 1, tasks: [] };The required program keeps one state object binding and changes its task data through explicit event handlers.
Select the stable interface regions
Section titled “Select the stable interface regions”Add these selections after the state declaration:
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.
Render the task records
Section titled “Render the task records”A render function reads the current state and updates the interface to match it. Add this function after the missing-elements guard:
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:
renderTasks();Save and reload. The page now shows two labeled checkboxes. The first is checked, and the summary says 1 of 2 tasks complete.
Read the render function in phases
Section titled “Read the render function in phases”replaceChildren()removes the list items from the previous render.for...ofvisits each task object instate.tasks.createElementcreates one list item, checkbox, and label for that task.- DOM properties connect the checkbox state, unique ID, label, and visible text to the record.
- The
changelistener updates the record and renders again. appendbuilds the list-item structure and adds it to the list.- The focus condition restores focus after a checkbox-triggered re-render.
filtercreates an array containing only completed records..lengthgives the derived count.textContentwrites 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.
Why render receives a focus ID
Section titled “Why render receives a focus ID”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.
Test completion changes
Section titled “Test completion changes”- Use Tab to focus Add keyboard checks.
- Press Space to check it.
- Confirm that the summary changes to 2 of 2 tasks complete.
- Confirm that focus remains on the Add keyboard checks checkbox after the render.
- 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 a task through the state cycle
Section titled “Add a task through the state cycle”Add this handler before the final renderTasks() call:
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:
- Read and validate the input.
- Use
pushto add one task object tostate.tasks. - Increment
state.nextTaskIdso the next record receives a different ID. - Clear the validation state and reset the form.
- Render from the changed state.
- 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.
Test invalid and valid additions
Section titled “Test invalid and valid additions”- Submit the empty field. Confirm Enter a task description.,
aria-invalid="true", and input focus. - Enter only spaces and submit. Confirm the same result.
- Enter Run narrow viewport check and submit.
- Confirm that the input clears, the error clears, and a third unchecked task appears.
- Confirm that the summary says 1 of 3 tasks complete.
- Check the new task with the keyboard. Confirm 2 of 3 tasks complete. and stable checkbox focus.
- Add
<strong>Review</strong>and confirm that the literal tag text appears instead of astrongelement.
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.
Trace the complete architecture
Section titled “Trace the complete architecture”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 stateThe 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.
- Reload the page and confirm that the two initial records render with the summary 1 of 2 tasks complete.
- Point to the array, one task object, and each property type in the initial state.
- Explain why state can use const while state.nextTaskId and state.tasks still change.
- Submit empty and whitespace-only values and confirm that state.tasks.length does not change.
- Add Run narrow viewport check and confirm one new record, one new list item, and the summary 1 of 3 tasks complete.
- Change the new checkbox with the keyboard and confirm that the matching record, summary, and focus all update correctly.
- Add <strong>Review</strong> and confirm that the label contains literal text rather than a new strong element.
- Explain why completedCount is derived inside renderTasks instead of stored in state.
- 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.
Represent a deliberate absence value
Section titled “Represent a deliberate absence value”Use null when a property must exist but deliberately has no active record yet:
const interfaceState = { selectedTask: null,};null is one JavaScript value. In this contract, it means no active task record. Test that meaning explicitly:
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.
Find one record by a stable identity
Section titled “Find one record by a stable identity”The visible text in a choice can change. A stable identity lets the program find the same record without depending on that text.
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:
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.
Represent a rectangular grid as records
Section titled “Represent a rectangular grid as records”A rectangular grid can use one ordered array of cell records. Each record identifies one position and the content at that position:
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:
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.
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.
Common state and rendering failures
Section titled “Common state and rendering failures”| 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.
Next step or safe stopping point
Section titled “Next step or safe stopping point”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.