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.
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.
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.
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
useActionStateanduseOptimisticshows the result at once and settles when the save does. - Test components by rendering into the stage and acting as a user would.