Emmanuel Akinfulubi

Project all 1 written

One reading list, end to end: rendered on the server, hydrated, filtered, added to, and tested, all by panels that really ran.

36 A reading list, end to end

Thirty-five lessons of small pieces. This one puts them together: a reading list that you can filter, that you can add a book to, that arrives as server-rendered HTML and comes alive in the browser, and that has tests. It is seven short files, and every file on this page is the file on disk, read when the page was built.

The pieces

books.js holds the data and the logic that needs no React: the starting list, a function that makes a book from what was typed, filterBooks, and a stand-in for a server called saveBook. Keeping the logic here means it can be tested as plain functions, as lesson 19 did for a reducer.

BookList shows a list of books and says so when there are none. Shelf is the page: the heading, a controlled filter box (lesson 11), and the list. AddBook is a form with an action (lesson 28), and it shows the new book at once with useOptimistic (lesson 29), marked (saving) until the save finishes. Because the list has to show the optimistic book, AddBook hands the books it is showing to its child, a function that decides how to draw them: Shelf uses that to filter, then draw the list. server.jsx turns the page into HTML (lesson 30), and client.jsx will attach React to that HTML in the browser (lesson 32).

On the server

The first panel runs the server side: it renders the page to a string and checks it. The string is printed only as far as the form. The form carries React's guard in its action, which lesson 28 explained once and which is not repeated here, so the panel checks the rest of the form without printing it.

The shelf, rendered on the server

ran at build time · server (no DOM) · React dev

server.jsx

import { renderToString } from 'react-dom/server';
import { Shelf } from './Shelf.jsx';
import { books } from './books.js';

export function page() {
  return renderToString(<Shelf initialBooks={books} />);
}

Shelf.jsx

import { useState } from 'react';
import { AddBook } from './AddBook.jsx';
import { BookList } from './BookList.jsx';
import { filterBooks } from './books.js';

export function Shelf({ initialBooks, save }) {
  const [query, setQuery] = useState('');
  return (
    <main>
      <h1>Reading list</h1>
      <label>
        Filter <input value={query} onChange={(event) => setQuery(event.target.value)} />
      </label>
      <AddBook initialBooks={initialBooks} save={save}>
        {(shown) => <BookList books={filterBooks(shown, query)} />}
      </AddBook>
    </main>
  );
}

BookList.jsx

export function BookList({ books }) {
  if (books.length === 0) return <p>No books match.</p>;
  return (
    <ul>
      {books.map((book) => (
        <li key={book.id}>{`${book.title} by ${book.author}${book.saving ? ' (saving)' : ''}`}</li>
      ))}
    </ul>
  );
}

AddBook.jsx

import { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
import { makeBook, saveBook } from './books.js';

function Submit() {
  const { pending } = useFormStatus();
  return <button>{pending ? 'Saving...' : 'Add'}</button>;
}

export function AddBook({ initialBooks, save = saveBook, children }) {
  const [books, formAction] = useActionState(async (current, formData) => {
    const title = String(formData.get('title')).trim();
    if (!title) return current;
    const book = makeBook(title, String(formData.get('author')));
    addOptimistic(book);
    return [...current, await save(book)];
  }, initialBooks);
  const [shown, addOptimistic] = useOptimistic(books, (current, book) => [
    ...current,
    { ...book, saving: true },
  ]);

  return (
    <>
      {children(shown)}
      <form action={formAction}>
        <label>
          Title <input name="title" />
        </label>
        <label>
          Author <input name="author" />
        </label>
        <Submit />
      </form>
    </>
  );
}

books.js

export const books = [
  { id: 'dune', title: 'Dune', author: 'Frank Herbert', read: true },
  { id: 'emma', title: 'Emma', author: 'Jane Austen', read: false },
  { id: 'kindred', title: 'Kindred', author: 'Octavia E. Butler', read: false },
];

export function makeBook(title, author) {
  const name = title.trim();
  return {
    id: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
    title: name,
    author: author.trim() || 'Unknown',
    read: false,
  };
}

export function filterBooks(list, query) {
  const wanted = query.trim().toLowerCase();
  if (!wanted) return list;
  return list.filter(
    (book) => book.title.toLowerCase().includes(wanted) || book.author.toLowerCase().includes(wanted),
  );
}

// Stands in for a server: it answers a moment later with the book it was sent.
export async function saveBook(book) {
  await Promise.resolve();
  return book;
}

render.jsx

import { page } from './server.jsx';

const html = page();
const end = html.indexOf('<form');
console.log(html.slice(0, end));

check('the page, up to the form', html.slice(0, end), '<main><h1>Reading list</h1><label>Filter <input value=""/></label><ul><li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li></ul>');
check('three inputs: the filter, the title and the author', (html.match(/<input/g) || []).length, 3);
check('and a button to add', html.includes('<button>Add</button>'), true);

What node printed (React 19.2.7, development build), when the build ran it

node render.jsx

<main><h1>Reading list</h1><label>Filter <input value=""/></label><ul><li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li></ul>
ok   the page, up to the form
ok   three inputs: the filter, the title and the author
ok   and a button to add

In the browser, hydrated

The second panel plays the browser. It puts the server's HTML in the stage, remembers the list, and calls start from client.jsx, which hydrates. It then does what a person would: types in the filter, and adds a book. The save is held open by a promise the panel releases, so the panel can look at the list while the book is still saving, and again once it is saved. It prints the list each time, and never the form.

The shelf, hydrated and used

ran at build time · in happy-dom 20.11.6 · React dev

client.jsx

import { hydrateRoot } from 'react-dom/client';
import { Shelf } from './Shelf.jsx';
import { books, saveBook } from './books.js';

// On a real page: start(document.getElementById('root'))
export function start(container, save = saveBook) {
  return hydrateRoot(container, <Shelf initialBooks={books} save={save} />);
}

browser.jsx

import { act } from 'react';
import { page } from './server.jsx';
import { start } from './client.jsx';

function type(input, text) {
  const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  set.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

const list = () => stage.querySelector('ul').innerHTML;
const names = () => Array.from(stage.querySelectorAll('li')).map((li) => li.textContent);

let release;
const save = (book) => new Promise((resolve) => (release = () => resolve(book)));

stage.innerHTML = page();
const before = stage.querySelector('ul');
const filter = stage.querySelector('input');

let root;
await act(async () => {
  root = start(stage, save);
});
const hydrated = stage.querySelector('ul') === before;
console.log('1', list());

await act(async () => type(filter, 'jane'));
const found = names();
console.log('2', list());
await act(async () => type(filter, ''));
const all = names();
console.log('3', list());

const [title, author] = stage.querySelectorAll('form input');
title.value = 'Beloved';
author.value = 'Toni Morrison';
await act(async () => stage.querySelector('form').requestSubmit());
const saving = names();
const busy = stage.querySelector('button').textContent;
console.log('4', list(), busy);

await act(async () => release());
const saved = names();
console.log('5', list(), stage.querySelector('button').textContent);

check('hydration kept the list from the server', hydrated, true);
check('typing "jane" leaves Emma', found, ['Emma by Jane Austen']);
check('clearing the filter brings all three back', all.length, 3);
check('the new book is there at once, marked saving', saving.at(-1), 'Beloved by Toni Morrison (saving)');
check('and the button says the form is busy', busy, 'Saving...');
check('once saved, the mark is gone', saved.at(-1), 'Beloved by Toni Morrison');
check('and the list has four books', saved.length, 4);
await act(async () => type(filter, 'toni'));
check('the filter finds the new book too', names(), ['Beloved by Toni Morrison']);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node browser.jsx

1 <li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li>
2 <li>Emma by Jane Austen</li>
3 <li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li>
4 <li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li><li>Beloved by Toni Morrison (saving)</li> Saving...
5 <li>Dune by Frank Herbert</li><li>Emma by Jane Austen</li><li>Kindred by Octavia E. Butler</li><li>Beloved by Toni Morrison</li> Add
ok   hydration kept the list from the server
ok   typing "jane" leaves Emma
ok   clearing the filter brings all three back
ok   the new book is there at once, marked saving
ok   and the button says the form is busy
ok   once saved, the mark is gone
ok   and the list has four books
ok   the filter finds the new book too

Tested

The last panel is a terminal. shelf.test.jsx tests the two functions in books.js as plain functions, and tests the shelf the way lesson 34 tested a button: render it into the stage, type, click, and read what is on the screen, using act. The last test holds the save open, as the panel above did, to look at the optimistic book. The command is the one from lesson 34. The run exits 0, which is what makes the build accept it.

The shelf, tested

ran at build time · terminal · React dev

shelf.test.jsx

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Shelf } from './Shelf.jsx';
import { books, filterBooks, makeBook } from './books.js';

function type(input, text) {
  const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  set.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

const titles = () => Array.from(stage.querySelectorAll('li')).map((li) => li.textContent);

test('filterBooks matches the title or the author, ignoring case', () => {
  assert.deepEqual(filterBooks(books, 'DUNE').map((b) => b.id), ['dune']);
  assert.deepEqual(filterBooks(books, 'austen').map((b) => b.id), ['emma']);
  assert.equal(filterBooks(books, '  ').length, 3);
});

test('makeBook builds an id from the title and fills in a missing author', () => {
  assert.deepEqual(makeBook(' The Dispossessed ', ''), {
    id: 'the-dispossessed',
    title: 'The Dispossessed',
    author: 'Unknown',
    read: false,
  });
});

test('the shelf lists the books and filters them as you type', async () => {
  const root = createRoot(stage);
  await act(async () => root.render(<Shelf initialBooks={books} />));
  assert.equal(titles().length, 3);

  await act(async () => type(stage.querySelector('input'), 'jane'));
  assert.deepEqual(titles(), ['Emma by Jane Austen']);

  await act(async () => root.unmount());
});

test('adding a book shows it at once, then keeps it', async () => {
  let release;
  const save = (book) => new Promise((resolve) => (release = () => resolve(book)));
  const root = createRoot(stage);
  await act(async () => root.render(<Shelf initialBooks={books} save={save} />));

  const [title, author] = stage.querySelectorAll('form input');
  title.value = 'Beloved';
  author.value = 'Toni Morrison';
  await act(async () => stage.querySelector('form').requestSubmit());
  assert.equal(titles().at(-1), 'Beloved by Toni Morrison (saving)');

  await act(async () => release());
  assert.equal(titles().at(-1), 'Beloved by Toni Morrison');
  assert.equal(titles().length, 4);

  await act(async () => root.unmount());
});

Also on disk, exactly as printed in an earlier panel: server.jsx, Shelf.jsx, BookList.jsx, AddBook.jsx, books.js

The terminal (React 19.2.7, development build), as the build ran it

node --test --test-isolation=none shelf.test.jsx
✔ filterBooks matches the title or the author, ignoring case (<n>ms)
✔ makeBook builds an id from the title and fills in a missing author (<n>ms)
✔ the shelf lists the books and filters them as you type (<n>ms)
✔ adding a book shows it at once, then keeps it (<n>ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms <n>

NODE_OPTIONS loads the course preload into every node this terminal runs, so each one records which React build it loaded.

You have now seen every piece run. Components and state, effects, context and reducers, memoisation, Suspense and actions, the server and the browser, tests and accessibility. What this course left out is the framework that wires the server and the browser together for you, with routing and data loading; the Next.js course picks up there, with these same ideas under it.

  • Put logic that needs no React in plain functions, and test it as such.
  • The server renders the page to HTML; the client hydrates the same tree and keeps its nodes.
  • An action with useActionState and useOptimistic shows the result at once and settles when the save does.
  • Test components by rendering into the stage and acting as a user would.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.