CodingNeed.

3 · Connect, test and build · 35 MIN

Project · Build a filterable reading list

Combine small concepts into one interface with clear ownership.

This project combines one owner for the books array, a controlled title input and a checkbox for filtering. Each book has a stable id and a read flag. Adding creates a new array; toggling uses map to replace only the matching object. The visible list and read count are derived from books, avoiding competing sources of truth. It is a local demonstration: refresh resets the state. Persistence and server authorization belong in later courses after this flow feels comfortable.

Run the unmodified example and trace one book from form to list. Describe which state is stored and which values are derived. Delete with filter using a book id, not its title. Test duplicates, an empty list and removing the last unread item.

Before you start

Install Node.js 22.12+ (or a supported newer LTS). In a terminal run: npm create vite@latest codingneed-react -- --template react. Then cd codingneed-react, npm install, and npm run dev. Open the local URL printed by Vite. Replace src/App.jsx with ONE lesson example at a time. JSX renders a browser interface; it is not a plain console script.

File for this example: App.jsx

New words, explained

immutableUpdate
Create a changed array or object without modifying the old one.
stableId
An identity that does not change when an item is renamed or moved.
acceptanceCheck
An observable behavior that tells you an exercise works.

Follow the example step by step

  1. Run the unmodified example and trace one book from form to list.
  2. Describe which state is stored and which values are derived.
  3. Delete with filter using a book id, not its title.
  4. Test duplicates, an empty list and removing the last unread item.

You are ready to move on when: Adding blank titles creates no book. Deleting a filtered item keeps counts correct. Two books with the same title remain independent. Tab, Enter and Space can operate the form and checkboxes.

Read the example

import {useState} from 'react';
export default function App() {
  const [books, setBooks] = useState([{id:'first', title:'React notes', read:false}]);
  const [title, setTitle] = useState('');
  const [unreadOnly, setUnreadOnly] = useState(false);
  function add(event) {
    event.preventDefault();
    if (!title.trim()) return;
    const book = {id:crypto.randomUUID(), title:title.trim(), read:false};
    setBooks(items => [...items, book]); setTitle('');
  }
  const visible = books.filter(book => !unreadOnly || !book.read);
  return <main><h1>My reading list</h1><form onSubmit={add}>
    <label>Book title <input value={title} onChange={e => setTitle(e.target.value)} /></label>
    <button>Add book</button></form>
    <label><input type="checkbox" checked={unreadOnly} onChange={e => setUnreadOnly(e.target.checked)} />Unread only</label>
    <p>{books.filter(book => book.read).length} of {books.length} read</p>
    <ul>{visible.map(book => <li key={book.id}><label><input type="checkbox" checked={book.read}
      onChange={() => setBooks(items => items.map(item => item.id === book.id ? {...item, read:!item.read} : item))} />{book.title}</label></li>)}</ul>
    {!visible.length && <p>No books match this view.</p>}
  </main>;
}
Check the expected output
Add a book, mark it read, enable Unread only and observe it disappear from that view. The read count still includes it.

Your challenge

Add a Delete button for each book. Disable adding titles longer than 80 characters and show a visible error. Test all controls using only the keyboard.

Solution cost: Discuss the operations in this small example; rendering and I/O costs depend on the host. time · Proportional to the example’s retained data. space

Common trap

Mutating book.read directly changes an existing object and makes state updates harder to reason about.

Study the project implementation
import {useState} from 'react';
export default function App() {
  const [books, setBooks] = useState([{id:'first', title:'React notes', read:false}]);
  const [title, setTitle] = useState('');
  const [unreadOnly, setUnreadOnly] = useState(false);
  function add(event) {
    event.preventDefault();
    if (!title.trim()) return;
    const book = {id:crypto.randomUUID(), title:title.trim(), read:false};
    setBooks(items => [...items, book]); setTitle('');
  }
  const visible = books.filter(book => !unreadOnly || !book.read);
  return <main><h1>My reading list</h1><form onSubmit={add}>
    <label>Book title <input value={title} onChange={e => setTitle(e.target.value)} /></label>
    <button>Add book</button></form>
    <label><input type="checkbox" checked={unreadOnly} onChange={e => setUnreadOnly(e.target.checked)} />Unread only</label>
    <p>{books.filter(book => book.read).length} of {books.length} read</p>
    <ul>{visible.map(book => <li key={book.id}><label><input type="checkbox" checked={book.read}
      onChange={() => setBooks(items => items.map(item => item.id === book.id ? {...item, read:!item.read} : item))} />{book.title}</label></li>)}</ul>
    {!visible.length && <p>No books match this view.</p>}
  </main>;
}

Further reading: Official documentation

Essential cookies keep your account signed in. Optional analytics is not configured on this site. Your choice does not affect access to lessons.

Read the Privacy Policy. You can change this choice in the footer.