Learning path · Level 5 · Tutorial 3

Keep Interface State and Rendering Consistent

Build a small interface over an in-memory list whose controls, results, and summaries remain consistent through explicit state updates and rendering.

  • Level: Intermediate
  • Duration: 20 min
  • 7 steps
Start tutorial Back to the path
Keep Interface State and Rendering Consistent

What you will go through

  1. Choose What the Interface Must Remember Identify the Book Finder values that belong in state and separate them from displayed output. 2 min
  2. Calculate Results Instead of Storing Copies Derive Book Finder results, counts, and control availability from current state instead of storing duplicate output values. 3 min
  3. Render One Consistent Snapshot Construct a render function that updates populated results, summaries, and controls from the same state snapshot. 3 min
  4. Update State Before Rendering Connect both Book Finder filters to one event-to-state-to-render cycle, keeping derived output updates inside render(). 3 min
  5. Handle Startup and Empty Results Render startup, zero-match, and recovery states through one consistent state-to-render path. 3 min
  6. Keep Controls Stable While Results Change Keep the interactive controls stable while render() replaces only the read-only output that changed. 3 min
  7. Build and Check the Complete Interface Build the complete local Book Finder and verify that repeated interactions keep its controls, results, counts, and empty states consistent. 4 min

What you will learn

  • Identify the values that must be stored and the values that can be derived.
  • Update state in event handlers before rendering the resulting interface.
  • Render initial, populated, and empty results from the same state model.
  • Check repeated interactions for stale results, incorrect counts, and unwanted focus loss.

Before you start

  • Handle Browser Events with Listeners and Delegation
  • Make Non-Mutating Updates with Spread Syntax

Step 1 of 7

Choose What the Interface Must Remember

Identify the Book Finder values that belong in state and separate them from displayed output.

Remember the source, not the screen

One source of truth

A small interface needs one reliable memory of its current situation. In Book Finder, that memory is an explicit state object.

State is the source of truth: when the interface changes later, the displayed page must be based on this object rather than on scattered, independently updated DOM text.

For this interface, state remembers the book list and the user’s two filter choices. It does not remember every piece of output currently visible on screen.

State and displayed output

A diagram shows book data and filter controls feeding one state object, which then feeds a results list and summary.

The interface remembers source data and choices; rows and summaries are displayed output from that source.

Store inputs to the interface

Initial Book Finder state

In app.js, begin with the fixed source list and one state object.

javascript
const initialBooks = [
  { id: 1, title: "River Walk", available: true },
  { id: 2, title: "City Lights", available: false },
  { id: 3, title: "Night Sky", available: true },
  { id: 4, title: "River Stories", available: false }
];

let state = { books: initialBooks, query: "", availableOnly: false };

What this object remembers

books is source data. query preserves exactly what the user entered, and availableOnly preserves the checkbox choice.

These values must be stored because the interface needs them again after each interaction. An empty search string and false describe the starting choices; they are meaningful state values, not missing output.

Tip

What it does not remember

Do not create separate state fields for visible rows or a displayed count. Those are derived values: output determined from the current state.

Later, the interface will derive them whenever it needs to render. This avoids giving the same fact two authorities.

Why duplicate authorities fail

Avoid contradictory output

If code independently edits a results list and a count, they can disagree. For example, an old row might remain visible while a separately updated summary says “Showing 0 of 4 books.”

Neither output alone is trustworthy because they no longer describe the same state. Keeping only the source list and filter choices as state gives later rendering one consistent basis for both outputs.

Example

A practical decision rule

A useful test is: “Would the interface need this value to reconstruct its current view?”

  • If it is source data or a user choice, store it in state.
  • If it can be produced from current state for display, treat it as a derived value.

This division prevents stale output when the user changes a filter.

Check your model

Classify the interface values

Match each Book Finder value to its role.

Stored or derived?

Match each value with its correct role.

Tap an item and then its matching pair.

Step 2 of 7

Calculate Results Instead of Storing Copies

Derive Book Finder results, counts, and control availability from current state instead of storing duplicate output values.

Separate remembered values from calculated values

Keep input state raw; derive comparison state

state keeps facts the interface must remember: the source books, the exact search text, and whether the availability filter is selected. It does not need separate fields for filtered books or displayed counts.

A user might type RiVeR . Keep that raw text in state.query so the control can reflect what they entered. For comparison only, calculate normalizedQuery by trimming spaces and converting to lowercase. That temporary value is a derived value, not another authority to store.

One source, several derived values

Diagram showing source books and filter choices producing visible books, a count, and control availability.

The source list and filter choices are state. Matching books, counts, and whether filters are usable are derived from that state.

Derive a filtered list from current state

Combine the two filter conditions

getVisibleBooks() derives a fresh list every time it is called. Each book must satisfy both conditions:

  • Its lowercase title includes normalizedQuery.
  • Either the availability filter is off, or the book is available.

When availableOnly is false, the availability condition lets every book through. When it is true, only books whose available value is true pass it.

app.js — derive visible books

Derive matching books from the current state.

javascript
function getVisibleBooks() {
  const normalizedQuery = state.query.trim().toLowerCase();

  return state.books.filter((book) => {
    const matchesQuery = book.title.toLowerCase().includes(normalizedQuery);
    const matchesAvailability = !state.availableOnly || book.available;

    return matchesQuery && matchesAvailability;
  });
}

Tip

Raw text and comparison text have different jobs

Do not overwrite state.query with normalizedQuery. The first preserves the user's input; the second is only a comparison helper. For example, spaces and capitalization should not prevent a title match, but they also should not silently change what the user typed.

Use the right source for each derived value

Derive counts and filter availability independently

Use the derived list for the visible count, but use the source list for the total count and filter availability. This prevents a zero-match search from making the filters unusable: the source books are still present, so the user needs the controls to recover.

app.js — derived counts and source availability

These values are calculated from state; they are not extra fields in state.

javascript
const visibleBooks = getVisibleBooks();
const hasBooks = state.books.length > 0;
const summaryText = `Showing ${visibleBooks.length} of ${state.books.length} books.`;
const controlsDisabled = !hasBooks;

Derived outcomes for key states

Compare the source list with the filtered result.

A nonempty source list keeps filters usable even when no books match.
Source booksQuery / Available onlyVisible booksSummaryControls disabled?
4 booksempty / false4Showing 4 of 4 books.No
4 booksriver / false2Showing 2 of 4 books.No
4 booksriver / true1Showing 1 of 4 books.No
4 booksocean / false0Showing 0 of 4 books.No
0 booksany query / either value0Showing 0 of 0 books.Yes

Check a combined filter and a zero-match state

Apply the derived-values model

Without running the code, write a short response with all four parts:

  1. The return condition that combines matchesQuery and matchesAvailability inside the filter.
  2. The number of visible books for query river when Available only is off.
  3. The number of visible books after Available only is turned on.
  4. Whether the filter controls should be disabled when query ocean has zero matches but the source still has four books, and why.

Write at least 45 characters (0/45).

Step 3 of 7

Render One Consistent Snapshot

Construct a render function that updates populated results, summaries, and controls from the same state snapshot.

Map state to interface regions

Render from current state

One source, one displayed snapshot

render() has one job: make the DOM reflect the current state and its derived values. It does not decide new state, edit state, or attach listeners.

For Book Finder, one render reads state, derives the current books once, and uses that snapshot to update the controls, summary, and result rows. If all three regions come from the same snapshot, they cannot disagree because one region used an older calculation.

One snapshot feeds every region

Diagram showing state flowing into one derived book collection, which then supplies the controls, summary, and result list.

Derive the visible books once, then let that same result drive both the rows and the count.

Semantic HTML scaffold

Create the persistent interface structure in index.html. The empty-message region is included now; its visibility rule is added in the next step.

html
<main id="book-finder">
  <h1>Book Finder</h1>

  <label for="search">Search books</label>
  <input id="search" type="text">

  <label>
    <input id="available-only" type="checkbox">
    Available only
  </label>

  <p id="summary"></p>
  <ul id="results"></ul>
  <p id="empty-message" hidden></p>
</main>

<script src="app.js" defer></script>

Build the render boundary

Keep rendering one-way

In app.js, select the stable regions once. Then render() updates their live properties and text from state.

Notice the boundary: render() reads state and writes DOM output. It does not contain state = ..., and it does not call addEventListener(). Those actions belong elsewhere in the interface lifecycle.

A consistent render function

Add this after the established state and getVisibleBooks() code. This version prepares populated rows and controls; empty-message visibility is handled in the next step.

javascript
const searchInput = document.querySelector("#search");
const availableOnlyInput = document.querySelector("#available-only");
const summary = document.querySelector("#summary");
const resultsList = document.querySelector("#results");
const emptyMessage = document.querySelector("#empty-message");

function render() {
  const visibleBooks = getVisibleBooks();
  const hasBooks = state.books.length > 0;

  if (searchInput.value !== state.query) {
    searchInput.value = state.query;
  }
  availableOnlyInput.checked = state.availableOnly;
  searchInput.disabled = !hasBooks;
  availableOnlyInput.disabled = !hasBooks;

  summary.textContent = `Showing ${visibleBooks.length} of ${state.books.length} books.`;

  const bookNodes = visibleBooks.map((book) => {
    const row = document.createElement("li");
    const availability = book.available ? "Available" : "Unavailable";
    row.textContent = `${book.title} — ${availability}`;
    return row;
  });

  resultsList.replaceChildren(...bookNodes);
}

Tip

Replace output; do not accumulate it

replaceChildren(...bookNodes) replaces the list's old rows with the rows for this render. Unlike repeated append() calls, it cannot leave rows from a prior state mixed with current results.

The guarded search assignment keeps the control synchronized without writing a value when it already matches the raw state.query. The raw text remains state; a normalized comparison value is only a derived value.

Practice: render rows and count together

Complete the shared snapshot

Complete the two missing operations so the summary and rows are built from one derived result and old rows are not retained.

javascript
function render() {
  const visibleBooks = ();

  summary.textContent = `Showing ${visibleBooks.length} of ${state.books.length} books.`;

  const bookNodes = visibleBooks.map((book) => {
    const row = document.createElement("li");
    const availability = book.available ? "Available" : "Unavailable";
    row.textContent = `${book.title} — ${availability}`;
    return row;
  });

  resultsList.(...bookNodes);
}

Check repeated rendering

Check repeatable rendering

The same state is rendered twice. Which approach keeps the result list consistent without changing state?

Step 4 of 7

Update State Before Rendering

Connect both Book Finder filters to one event-to-state-to-render cycle, keeping derived output updates inside render().

Use the same path for both filters

One update cycle

A browser event is only the start of an interface update. For either Book Finder filter, use one deliberate cycle:

  1. The browser emits an input or change event.
  2. The handler reads the changed control and replaces state.
  3. render() reads that current state, calculates derived values, and makes the DOM reflect them.

This keeps one authority for the interface. A handler should not separately rewrite result rows or the summary; those are rendered outputs of state.

From event to rendered interface

Diagram showing a search input and checkbox feeding one state object, then derived values feeding a summary and result list.

Both controls follow the same path: browser event, state update, derived values, then rendered output.

Keep handlers focused

Each handler changes only the state property owned by its control, then calls render(). Object spread retains every other state property. Listener registration belongs in setup, outside render(), so each listener is attached once rather than added again on every update.

Handlers and one-time listener setup

Define these handlers after the existing state, element references, getVisibleBooks(), and render() function.

javascript
function handleSearchInput(event) {
  state = { ...state, query: event.currentTarget.value };
  render();
}

function handleAvailabilityChange(event) {
  state = { ...state, availableOnly: event.currentTarget.checked };
  render();
}

searchInput.addEventListener("input", handleSearchInput);
availableOnlyInput.addEventListener("change", handleAvailabilityChange);

Why order matters

Spot the stale render

What is the sequencing problem in this handler?

function handleSearchInput(event) {
  render();
  state = { ...state, query: event.currentTarget.value };
}

Trace one interaction

Order the cycle

Arrange the Book Finder update cycle from first to last.

  1. render() calculates derived values.
  2. The handler reads the changed control.
  3. The handler replaces state with the updated value.
  4. The browser emits an input or change event.
  5. render() makes the output reflect current state.

Apply the update-before-render rule

Complete the availability handler

Complete the missing statement so this handler updates the checkbox-related state value before rendering.

javascript
function handleAvailabilityChange(event) {
  
  render();
}

Step 5 of 7

Handle Startup and Empty Results

Render startup, zero-match, and recovery states through one consistent state-to-render path.

Start from the state model

Render at startup

The interface should be correct before anyone types or changes a checkbox. After selecting elements and registering the two listeners, call render() once.

That first call uses the same state, derived values, and rendering path that later interactions use. Do not create separate “initial” rows or a separately maintained initial count: those could disagree with the interface after a filter changes.

Setup order

Finish setup by calling render after the listeners are registered.

javascript
searchInput.addEventListener("input", handleSearchInput);
availableOnlyInput.addEventListener("change", handleAvailabilityChange);

render();

One rendering path, three interface states

A diagram showing startup with four rows, a filtered empty result with no rows, and recovery with two rows, all produced by the same render process.

The state changes; render() updates the same interface regions for startup, empty results, and recovery.

Render every empty case

Two kinds of empty results

An empty result does not always mean there are no source books. Keep these cases distinct on every render:

  • When hasBooks is true but visibleBooks is empty, filters excluded every book. Show No books match your filters. The controls stay enabled because the source list exists.
  • When hasBooks is false, there is no source data. Show No books in this list. The controls are disabled from !hasBooks.

In both cases, update the summary, empty-message visibility, and result children. Replacing the list children with an empty bookNodes array removes rows from the previous render instead of leaving stale results behind.

Render empty and populated results consistently

This portion of render() updates all output regions from the current derived values. It runs whether the result has four rows, zero rows, or any other count.

javascript
const visibleBooks = getVisibleBooks();
const hasBooks = state.books.length > 0;

searchInput.disabled = !hasBooks;
availableOnlyInput.disabled = !hasBooks;
summary.textContent = `Showing ${visibleBooks.length} of ${state.books.length} books.`;

if (!hasBooks) {
  emptyMessage.textContent = "No books in this list.";
} else {
  emptyMessage.textContent = "No books match your filters.";
}
emptyMessage.hidden = visibleBooks.length > 0;

const bookNodes = visibleBooks.map((book) => {
  const bookItem = document.createElement("li");
  const availability = book.available ? "Available" : "Unavailable";
  bookItem.textContent = `${book.title} — ${availability}`;
  return bookItem;
});

resultsList.replaceChildren(...bookNodes);

Tip

Check the empty source without new features

To check the empty-source branch locally, temporarily change the local initialization to const initialBooks = []; and reload the page. This is a test of rendering, not a reason to add a deletion feature. Restore the four-book array afterward.

Apply the complete empty-results logic

Complete the startup and recovery path

Complete the startup call, the empty-message conditions, and the result transition for the established four-book Book Finder.

javascript
// Setup ends after element references and listener registration.
();

// Inside render(), after visibleBooks and hasBooks are derived:
summary.textContent = `Showing ${visibleBooks.length} of ${state.books.length} books.`;

if (!hasBooks) {
  emptyMessage.textContent = ;
} else {
  emptyMessage.textContent = ;
}
emptyMessage.hidden = ;
resultsList.replaceChildren(...bookNodes);

// With query "city" and Available only enabled:
const cityAvailableCount = ;

// After clearing the query while Available only remains enabled:
const clearedAvailableCount = ;

Step 6 of 7

Keep Controls Stable While Results Change

Keep the interactive controls stable while render() replaces only the read-only output that changed.

Preserve the interface shell

Keep the interactive shell

A render should preserve the DOM nodes that users interact with. In Book Finder, keep #book-finder, the search input, the availability checkbox, and #results in place across renders.

The changing part is the read-only set of result rows. Recreating only those <li> elements is safe because users do not type into or focus them. This narrow boundary keeps control identity stable while current state produces different results.

A narrow rendering boundary

Diagram showing a stable search input, checkbox, application container, and results-list frame while only result rows are replaced.

Keep the controls and list element; replace only the list's read-only children.

Synchronize without rebuilding

Render into stable nodes

This portion of render() synchronizes existing controls and replaces only the current result rows.

javascript
function render() {
  const visibleBooks = getVisibleBooks();
  const hasBooks = state.books.length > 0;

  if (searchInput.value !== state.query) {
    searchInput.value = state.query;
  }
  availableOnlyInput.checked = state.availableOnly;
  searchInput.disabled = !hasBooks;
  availableOnlyInput.disabled = !hasBooks;

  const bookNodes = visibleBooks.map((book) => {
    const item = document.createElement("li");
    const availability = book.available ? "Available" : "Unavailable";
    item.textContent = `${book.title} — ${availability}`;
    return item;
  });

  resultsList.replaceChildren(...bookNodes);
}

Tip

Preserve what the user typed

The guard avoids assigning the same input value during ordinary typing. In particular, do not write normalizedQuery back to the search field: state keeps the user's raw text, while normalization is only a derived value used for matching.

Attach once; keep the nodes

Listeners belong in setup, outside render(). Because searchInput and availableOnlyInput remain the same nodes, their existing listeners continue to work after every result update. Rebuilding their parent would remove those nodes and their attached listeners.

Test the unsafe alternative

Check the rendering boundary

True or false: If a newly created #book-finder container has identical markup, replacing the old container on every search input is a safe rendering strategy.

Explain the safe boundary

Apply the stable-rendering rule

A user is typing in the search field when render() updates the results. A teammate proposes rebuilding all of #book-finder with replaceChildren(...) after every input event.

State which region should be replaced instead, write the guarded assignment that synchronizes the search field from state.query, and explain why recreating the whole application is unsafe even when it looks identical.

Write at least 120 characters (0/120).

Step 7 of 7

Build and Check the Complete Interface

Build the complete local Book Finder and verify that repeated interactions keep its controls, results, counts, and empty states consistent.

Create the persistent interface

Set up the persistent page structure

Create two files in the same local folder: index.html and app.js. Keep the #book-finder element, both filter controls, and the output regions in the document for the whole session. Only the read-only list items will be replaced later.

Save this complete HTML file, then open or refresh index.html in your browser after saving both files.

Keep the controls; replace result rows

Diagram showing one persistent Book Finder interface whose controls, summary, message, and results are updated from state, with only list rows replaced.

The container and controls remain in place. A render changes their properties and replaces only the result-list children.

index.html — complete file

Save this complete file as index.html.

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Book Finder</title>
</head>
<body>
  <main id="book-finder">
    <h1>Book Finder</h1>

    <label for="search">Search books</label>
    <input id="search" type="text">

    <label>
      <input id="available-only" type="checkbox">
      Available only
    </label>

    <p id="summary"></p>
    <ul id="results"></ul>
    <p id="empty-message" hidden></p>
  </main>

  <script src="app.js" defer></script>
</body>
</html>

Build the complete local script

Add the complete state-to-render script

Create app.js. Add the following three sections in the shown order to form one complete file. The last section attaches listeners once and performs the initial render.

app.js — section 1 of 3

Section 1 of 3 — state, references, and derived values.

javascript
const initialBooks = [
  { id: 1, title: "River Walk", available: true },
  { id: 2, title: "City Lights", available: false },
  { id: 3, title: "Night Sky", available: true },
  { id: 4, title: "River Stories", available: false }
];

let state = { books: initialBooks, query: "", availableOnly: false };

const searchInput = document.querySelector("#search");
const availableOnlyInput = document.querySelector("#available-only");
const summary = document.querySelector("#summary");
const resultsList = document.querySelector("#results");
const emptyMessage = document.querySelector("#empty-message");

function getVisibleBooks() {
  const normalizedQuery = state.query.trim().toLowerCase();

  return state.books.filter((book) => {
    const matchesQuery = book.title.toLowerCase().includes(normalizedQuery);
    const matchesAvailability = !state.availableOnly || book.available;

    return matchesQuery && matchesAvailability;
  });
}

app.js — section 2 of 3

Section 2 of 3 — render the current state and its derived values.

javascript
function render() {
  const visibleBooks = getVisibleBooks();
  const hasBooks = state.books.length > 0;

  if (searchInput.value !== state.query) {
    searchInput.value = state.query;
  }
  availableOnlyInput.checked = state.availableOnly;
  searchInput.disabled = !hasBooks;
  availableOnlyInput.disabled = !hasBooks;

  summary.textContent = `Showing ${visibleBooks.length} of ${state.books.length} books.`;

  if (!hasBooks) {
    emptyMessage.textContent = "No books in this list.";
  } else {
    emptyMessage.textContent = "No books match your filters.";
  }
  emptyMessage.hidden = visibleBooks.length > 0;

  const bookNodes = visibleBooks.map((book) => {
    const row = document.createElement("li");
    const availability = book.available ? "Available" : "Unavailable";
    row.textContent = `${book.title} — ${availability}`;
    return row;
  });

  resultsList.replaceChildren(...bookNodes);
}

app.js — section 3 of 3

Section 3 of 3 — event handlers, one-time listeners, and startup.

javascript
function handleSearchInput(event) {
  state = { ...state, query: event.currentTarget.value };
  render();
}

function handleAvailabilityChange(event) {
  state = { ...state, availableOnly: event.currentTarget.checked };
  render();
}

searchInput.addEventListener("input", handleSearchInput);
availableOnlyInput.addEventListener("change", handleAvailabilityChange);

render();
Expected output
Search books: enabled, empty
Available only: enabled, unchecked
Showing 4 of 4 books.
River Walk — Available
City Lights — Unavailable
Night Sky — Available
River Stories — Unavailable
Empty message: hidden

Expected initial browser state after saving both files and loading or refreshing index.html. The empty message is present in the DOM but hidden.

Verify states, empty results, and recovery

Run the same checks every time

Save both files and refresh the browser. Use the sequence below in order. Compare what you see with every expected summary, list, and empty-result message.

For the source-empty check, temporarily change only the initialBooks array to [], save, and reload. Do not add a delete feature. Restore the complete four-book array afterward and reload again.

Expected browser checks

Each row describes the state after the action in that row.

A consistent interface makes all regions agree after every interaction.
ActionExpected summaryExpected results and message
Load the pageShowing 4 of 4 books.Four rows; no empty message
Type riverShowing 2 of 4 books.River Walk and River Stories
Enable Available onlyShowing 1 of 4 books.River Walk only
Replace river with cityShowing 0 of 4 books.No list rows; No books match your filters.
Clear the search fieldShowing 2 of 4 books.River Walk and Night Sky
Disable Available onlyShowing 4 of 4 books.All four original rows
Reload with source array []Showing 0 of 0 books.No list rows; “No books in this list.”; both filters disabled

Tip

Check repeated interaction and typing

After one full sequence, repeat it. Then type river, move the caret into the middle of the word, and insert one character. The search field should retain focus and the caret should remain usable while the result list changes. Remove that character and continue typing normally.

If the city check shows an old book row, or a count disagrees with the rows, inspect the state update and the single render pass rather than adding one-off DOM fixes.

Repair, apply, and conclude

Repair the search handler

Repair the missing final statement so this handler completes the required update cycle.

javascript
function handleSearchInput(event) {
  state = { ...state, query: event.currentTarget.value };
  
}

Final application: report your interface checks

Run your complete Book Finder again and write a short verification report. Include the exact summaries and visible book results for: initial load, river, availability enabled, city, clearing the query, and disabling the filter. State whether the city result left any old rows.

Then repeat the sequence and edit text in the middle of the search field. Report whether focus and caret continuity survived. Finally, temporarily use an empty source array, reload, and report the controls and message you observed before restoring the original data.

Write at least 350 characters (0/350).

Summary

Review the responsibility boundaries

Reusable checks for a small state-driven interface:

  • Event handlers replace state before requesting a render.
  • Helpers create derived values instead of maintaining duplicate counts or filtered lists.
  • One render pass keeps controls, rows, summaries, and empty results aligned.
  • Replacing read-only result children preserves the search control, its focus, and its listener.

Tutorial complete

Congratulations! You completed: Keep Interface State and Rendering Consistent

You built and checked a stable state-driven Book Finder, including populated, filtered, empty, and recovered results.

50 XP

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate