Skip to the content

Module 1: How a web page works

Module 2: Setting up your first project

Module 3: JSX from scratch

Module 4: Components

Module 5: Props: passing data down

Module 6: Lists and conditions

Module 7: Events and state

Module 8: Updating state the right way

Module 9: Forms and inputs

Module 10: Sharing state between components

Module 11: Effects, and when not to use them

Module 12: Refs and the DOM

Module 13: Reducers and context

Module 14: Custom Hooks and fetching data

Module 15: Accessible components

Module 16: Performance

Module 17: Suspense and transitions

Module 18: Actions, use and the server

Module 19: Under the hood

Module 20: Mastery: building and reviewing a large app

Answer key

  1. 1. How a web page works · CSS says how things look · QuizYou want the to-do list’s text to be bigger. Which language do you change?B. CSSHow things look is CSS’s job: a rule like li { font-size: 1.2rem }. HTML would only change what is there, and JavaScript is for behaviour, like reacting to a click.
  2. 1. How a web page works · Check: what happens on a click · Put in orderSomeone clicks “Add a to-do” on the page above. Put what happens in order.1. The browser notices the click on the button 2. It runs the function you gave to addEventListener 3. The function creates an li and adds it to the ul 4. The DOM has changed, so the browser draws the new itemThe browser owns the events and the drawing. Your code only sits in the middle: it reacts to an event by changing the DOM, and the screen follows the DOM.
  3. 1. How a web page works · Check: what happens on a click · QuizAfter the click, does index.html on the server contain the new to-do?B. No, only the DOM in this browser tab changedJavaScript changes the DOM, the copy in memory. The file is untouched, so reloading the page brings back the original list. Saving data is a separate job you will meet later.
  4. 2. Setting up your first project · Check: the steps to a running project · Put in orderPut the steps in order, from nothing to a page in the browser.1. Install Node.js (it brings npm) 2. Run npm create vite@latest to make the project 3. Move into the project folder with cd 4. Run npm install to download the packages 5. Run npm run dev and open the address it printsNode.js comes first because every other step runs on it. npm install must come before npm run dev: the dev server needs React and Vite downloaded into node_modules to start.
  5. 2. Setting up your first project · Save a file and the page updates · QuizYou close the terminal window that runs npm run dev. What happens to http://localhost:5173?B. It stops working: the dev server ran in that terminallocalhost means “this computer”. The dev server is a program running in your terminal, and only you can reach it. Close it and the address stops answering. Publishing a site for others is a separate step, npm run build, and a host.
  6. 2. Setting up your first project · Errors are messages, so read them · Find the bugThis file won’t compile. Which line is wrong?Line 3: }A function’s body opens with { on line 1, so it must close with }. A ) closes a parenthesis that was never opened. Brackets come in pairs, and most early errors are a missing or mismatched one.
  7. 3. JSX from scratch · Check: what ends up on the page · QuizWhat text does this show?B. 2 of 5 done, total - done leftOnly what is inside braces is JavaScript. total - done is outside them, so it is plain text and shows as it is written. To compute it, write {total - done}.
  8. 3. JSX from scratch · Check: what ends up on the page · Fill the blanksShow the to-do’s text inside the list item.1: {text}Braces around text tell JSX to use the variable’s value. Without them you would see the word “text”.
  9. 3. JSX from scratch · Every tag must be closed · Find the bugThis JSX won’t compile. Which line is the problem?Line 4: <input id="new-todo" />An input has no content, so in JSX it must close itself with />. Left open, the translator thinks the button and </form> are inside the input and stops at the mismatch.
  10. 3. JSX from scratch · Your first component returns JSX · QuizYou rename it function app() and write <app />. What happens?B. React makes an unknown HTML tag called app instead of calling your functionJSX decides by the first letter. Lowercase means a built-in HTML tag, passed as the text "app". Capitalised means a variable, your function. That is why component names must start with a capital.
  11. 3. JSX from scratch · Try it: a recipe card · Code challengeFinish RecipeCard: above the list, add a heading showing title, and a paragraph that reads Serves 4, using servings inside braces.One solution:
    Solution
    export default function RecipeCard() {  const title = 'Pancakes'  const servings = 4  return (    <article>      <h2>{title}</h2>      <p>Serves {servings}</p>      <ul>        <li>Flour</li>        <li>Eggs</li>        <li>Milk</li>      </ul>    </article>  )}
    Braces put a JavaScript value into the markup: {title} becomes the text Pancakes. Text and braces can sit side by side, as in Serves {servings}, and React joins them into one line. The usual slip is to leave the braces out: <h2>title</h2> shows the word “title”, not the value. Any heading level works, since the test asks for the heading role.
  12. 4. Components · One component per file, shared with import and export · Fill the blanksTodoList.tsx has export function TodoList(). Import it in App.tsx.1: { TodoList }, 2: ./TodoListA named export is imported with braces and its exact name. The path starts with ./ because the file sits next to App.tsx. Without the ./, the tools would look for a package called TodoList.
  13. 4. Components · The same component can appear many times · QuizWhen React renders Page above, how many times does it call Divider?B. Twice, once per <Divider />Every tag in the JSX is its own instance, and React calls the component for each one. That is what lets each copy have different data once you add props.
  14. 4. Components · Try it: five stars · Code challengeFinish Star so it returns <span>★</span>, and make Rating show five <Star />s inside its paragraph.One solution:
    Solution
    function Star() {  return <span>★</span>}
    export default function Rating() {  return (    <section>      <h2>The Hobbit</h2>      <p>        <Star />        <Star />        <Star />        <Star />        <Star />      </p>    </section>  )}
    Each <Star /> is its own use of the component: React calls Star five times and puts each result in place. A component that returns null draws nothing, which is why the starter shows no stars at all. Writing <span>★</span> five times would pass too, but then a change to the star means five edits; the component makes it one.
  15. 4. Components · Components must be pure · Find the bugThis component gives a different result each time React calls it. Which line breaks purity?Line 4Line 4 changes a variable that lives outside the component. React may call a component more often than you expect, so the numbers would jump around. Information a component needs should come in as props (next module), not from outside variables it changes.
  16. 4. Components · Strict Mode renders twice to catch impure code · QuizIn development, a console.log inside a component prints twice per render. Is something broken?B. No: Strict Mode calls components twice on purpose to find impuritiesThis is Strict Mode doing its job. React’s developer tools dim the second log so you can tell them apart. It only happens in development, so it costs nothing in the published app.
  17. 4. Components · Check: from a design to components · Put in orderYou are given a picture of the to-do app. Put the steps of turning it into components in order.1. Draw a box around each part of the picture and name it 2. Arrange the boxes into a tree: which box sits inside which 3. Write each box as a component that returns static JSX 4. Use the children inside their parents, starting from AppStart from the picture, not the code. Naming boxes and finding the tree first means each component has one clear job before you write it. Static JSX first, data later, is the approach the React docs call “thinking in React”.
  18. 5. Props: passing data down · Check: write a component with props · Fill the blanksComplete the component so <Greeting name="Sam" /> shows “Hello, Sam”.1: name, 2: {name}Destructure name out of the props object, then put it inside braces in the JSX. Without the braces you would see the word “name”.
  19. 5. Props: passing data down · Check: write a component with props · QuizWhat does React pass as the first argument to TodoItem for <TodoItem text="Buy milk" done={false} />?B. An object: { text: "Buy milk", done: false }A component always gets exactly one argument: an object holding every prop. Destructuring is just a short way of reading its fields.
  20. 5. Props: passing data down · Try it: a greeting with a default · Code challengeMake Greeting show Hello, Ada! for <Greeting name="Ada" />, and Hello, friend! when no name is given. Use a default value in the destructuring.One solution:
    Solution
    type GreetingProps = {  name?: string}
    export default function Greeting({ name = 'friend' }: GreetingProps) {  return <p>Hello, {name}!</p>}
    { name = 'friend' } reads the name prop and uses 'friend' when it is undefined, which is what a missing prop is. Writing name || 'friend' in the JSX works too, but it also replaces an empty string; the default only fills in a missing prop. Each <Greeting /> gets its own props, so the two in the last test greet different people.
  21. 5. Props: passing data down · Props are read-only · Find the bugThis component breaks a rule of props. Which line?Line 3: const text = todo.text || 'Untitled'Line 3 changes the parent’s object. The parent’s data now differs from what it thinks it has, and the change happened during rendering, so it isn’t pure either. Work out a new value in a local variable instead, and show {text}.
  22. 5. Props: passing data down · Data flows one way: down · QuizA TodoItem needs to tell the app it was ticked. With what you know about props, how could it?B. The parent passes it a function as a prop, and the child calls itData still flows down: the function is just another prop. When the child calls it, code in the parent runs, and the parent decides what changes. You will build exactly this in the events and state modules.
  23. 6. Lists and conditions · Keys tell React which row is which · QuizWhich is the best key for a to-do row?B. todo.id, made once when the to-do was createdA key must be unique and stable. Math.random() changes every render, so React rebuilds every row each time. Text can repeat (two “Buy milk”s). The index shifts when items move. An id made once with the item is both.
  24. 6. Lists and conditions · Filter first, then map · Fill the blanksShow only the finished to-dos, each with its id as key.1: filter, 2: todo.idfilter keeps the items whose function returns true, here the done ones. Then map turns each into a li, keyed by the to-do’s own id.
  25. 6. Lists and conditions · Inside JSX: the ternary and && · QuizWith no to-dos, what does this show?B. A 0a && b gives back a when a is falsy. todos.length is the number 0, and React does draw numbers, so a lone 0 appears. Compare explicitly: todos.length > 0 && …, which gives false, and false draws nothing.
  26. 6. Lists and conditions · A component can return null to show nothing · Find the bugThe list renders nothing at all. Which line is the bug?Line 5: return <li key={todo.id}>{todo.text}</li>With { } after the arrow, the function has a body and needs an explicit return. Without it, each call returns undefined, which draws nothing. Either add return, or use ( ) instead of { }, which returns the JSX directly.
  27. 6. Lists and conditions · Try it: a list with an empty state · Code challengeMake TodoList show only the to-dos that are not done, one li each. When none are left, show a paragraph Nothing to do. Enjoy. instead of the list.One solution:
    Solution
    type Todo = { id: number; text: string; done: boolean }
    const sample: Todo[] = [  { id: 1, text: 'Buy milk', done: false },  { id: 2, text: 'Walk the dog', done: true }]
    export default function TodoList({ todos = sample }) {  const open = todos.filter((todo) => !todo.done)  if (open.length === 0) {    return <p>Nothing to do. Enjoy.</p>  }  return (    <ul>      {open.map((todo) => (        <li key={todo.id}>{todo.text}</li>      ))}    </ul>  )}
    Filter first, then decide: the empty check must look at the filtered list, or a list of finished to-dos shows an empty ul instead of the message (the last test). An early return keeps the two screens apart. The key stays the to-do’s id, never its position, so rows keep their identity when the filter changes.
  28. 7. Events and state · Pass a function to onClick · Find the bugThe alert pops up as soon as the page loads, not on click. Which line?Line 5: return <button onClick={handleClick}>Clear done</button>handleClick() calls the function during rendering and passes its result (nothing) to onClick. handleClick without brackets passes the function itself, for React to call later. To pass an argument, wrap it: onClick={() => remove(id)}.
  29. 7. Events and state · A plain variable can’t make the page change · QuizYou click the button five times. What does it say?B. Clicked 0 timesTwo problems. Changing a local variable doesn’t tell React to render again, so the screen never updates. And even if it did render, let count = 0 would run again and start from zero. A component needs a way to remember a value and to ask for a new render. That is state.
  30. 7. Events and state · Try it: a counter · Code challengeMake the first button count its clicks (Clicked 1 times, Clicked 2 times…), and make Reset set the count back to 0.One solution:
    Solution
    import { useState } from 'react'
    export default function Counter() {  const [count, setCount] = useState(0)  return (    <div>      <button onClick={() => setCount(count + 1)}>        Clicked {count} times      </button>      <button onClick={() => setCount(0)}>Reset</button>    </div>  )}
    Each onClick gets a function for React to call later: () => setCount(count + 1). The setter stores the new number and asks for a render, and the render shows it. Writing onClick={setCount(count + 1)} instead calls the setter while rendering, which renders again, and again: React stops it with a “too many re-renders” error.
  31. 7. Events and state · Trigger, render, commit · Put in orderYou click the counter. Put what happens in order.1. The onClick handler calls setCount(1) 2. React stores 1 and schedules a render of Counter 3. React calls Counter; useState now returns 1 4. React compares the new JSX with the old and finds the text changed 5. React updates that text in the DOM, and the browser draws itThe setter doesn’t change the screen itself: it records the new value and asks for a render. Rendering is only calling your function. The DOM is touched in the commit, and only where the result differs.
  32. 7. Events and state · Each copy of a component has its own state · QuizA page shows <Counter /> twice. You click the first one three times. What do they show?B. 3 and 0Each <Counter /> is its own instance with its own memory. Only the clicked one’s setter ran, so only it changed.
  33. 8. Updating state the right way · State is a snapshot for one render · Quizcount is 0. You click once. What does it show after?B. 1In this render count is 0 on all three lines, so each line asks for 0 + 1. The next render gets 1. The setter doesn’t update the variable you are holding.
  34. 8. Updating state the right way · To build on the latest value, pass a function · Fill the blanksToggle open from whatever it currently is, safely.1: open, 2: !openAn updater gets the latest value and returns the next. Name the parameter anything; by habit it is the state’s name or its first letter. ! turns true into false and back.
  35. 8. Updating state the right way · Change one item with map · Find the bugTicking a box sometimes doesn’t update the screen. Which line is the bug?Line 3: if (t.id === id) return { ...t, done: !t.done }The array is new, but line 3 changed the old to-do object in place. Any component that compares that object (a memoised row, or the last render’s snapshot) sees “same object” and may not update. Copy the item you change, too.
  36. 8. Updating state the right way · Try it: tick and delete · Code challengeWrite toggle and remove with new arrays: ticking a box marks that to-do done (so 1 left), and its Delete button takes it off the list.One solution:
    Solution
    import { useState } from 'react'
    const start = [  { id: 1, text: 'Buy milk', done: false },  { id: 2, text: 'Walk the dog', done: false }]
    export default function Todos() {  const [todos, setTodos] = useState(start)  function toggle(id: number) {    setTodos((todos) =>      todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t))    )  }  function remove(id: number) {    setTodos((todos) => todos.filter((t) => t.id !== id))  }  const rows = todos.map((t) => (    <li key={t.id}>      <label>        <input type="checkbox" checked={t.done}          onChange={() => toggle(t.id)} />        {t.text}      </label>      <button onClick={() => remove(t.id)}>Delete</button>    </li>  ))  const left = todos.filter((t) => !t.done).length  return <div><p>{left} left</p><ul>{rows}</ul></div>}
    map makes a new array, and { ...t, done: !t.done } a new object for the one that changed, while the others are passed along as they are. filter makes a new array without the deleted one. Flipping t.done in place and calling setTodos(todos) hands React the same array, so it sees nothing new and skips the render; it also changes start, which the next test begins from.
  37. 8. Updating state the right way · Don’t store what you can work out · QuizIn a to-do app with a filter, which of these should be state? Pick all.A. The list of to-dos; B. The chosen filter (all, open, done)The to-dos and the filter change over time and can’t be worked out from anything else, so they are state. The filtered list and the count can be computed from those two on every render, so storing them would only create copies that can disagree.
  38. 9. Forms and inputs · Check: a frozen input · Find the bugYou can’t type in this input: every key press is ignored. Which line is missing a piece?Line 4: <input value={query} onChange={(e) => setQuery(e.target.value)} />With value but no onChange, React keeps putting query (empty) back after every key press. React warns about exactly this in the console. Either add onChange, or use defaultValue if you don’t need to control it.
  39. 9. Forms and inputs · Try it: a form that adds to-dos · Code challengeFinish handleSubmit: add the trimmed draft to todos (as { id, text }), then empty the field. Ignore a draft that is only spaces.One solution:
    Solution
    import { useState, type SubmitEvent } from 'react'
    type Todo = { id: number; text: string }
    export default function TodoForm() {  const [todos, setTodos] = useState<Todo[]>([])  const [draft, setDraft] = useState('')
      function handleSubmit(event: SubmitEvent) {    event.preventDefault()    const text = draft.trim()    if (text === '') return    setTodos([...todos, { id: todos.length + 1, text }])    setDraft('')  }
      return (    <form onSubmit={handleSubmit}>      <label>        New to-do{' '}        <input          value={draft}          onChange={(e) => setDraft(e.target.value)}        />      </label>      <button>Add</button>      <ul>        {todos.map((t) => <li key={t.id}>{t.text}</li>)}      </ul>    </form>  )}
    The form’s onSubmit runs for a click on its button and for Enter in the field, and preventDefault() stops the page reloading. Trim first, then check, so “ ” counts as empty. Clearing the field is just setDraft(''): the input is controlled, so it shows whatever the state says. The id here is the list’s length plus one, which is enough while nothing is deleted; a real app gets ids from its data.
  40. 9. Forms and inputs · Checkboxes, selects and text areas · Fill the blanksControl the “show finished” checkbox.1: checked, 2: checkedFor checkboxes the state is checked, a true or false, not value. e.target.value on a checkbox is the text "on", whatever its state, which is a classic mistake.
  41. 9. Forms and inputs · Editing a to-do in place · QuizElsewhere the to-do is renamed while this row isn’t editing. Then you double-click it. What does the input show?B. The old nameuseState(todo.text) only uses its argument on the first render. Later changes to the prop don’t reach draft. Fix it by setting the draft when editing starts: setDraft(todo.text); setEditing(true). The sharing-state module shows another fix, with a key.
  42. 10. Sharing state between components · The child becomes controlled by its parent · QuizA count of open to-dos now sits in the Header, also a child of App. Where should the to-dos live?C. In AppApp is the closest parent of everything that reads the to-dos. Two copies would drift apart. Put it in one place and pass down what each child needs: a single source of truth.
  43. 10. Sharing state between components · Try it: lift the search up · Code challengeMove query up into Bookshop. Make SearchBox controlled, with value and onChange props, and pass the query to BookList, so typing in Search narrows the list.One solution:
    Solution
    import { useState } from 'react'
    const books = ['Dune', 'Emma', 'Beloved', 'Middlemarch']
    type SearchProps = { value: string; onChange: (v: string) => void }
    function SearchBox({ value, onChange }: SearchProps) {  return (    <label>      Search{' '}      <input value={value} onChange={(e) => onChange(e.target.value)} />    </label>  )}
    function BookList({ query }: { query: string }) {  const q = query.toLowerCase()  const shown = books.filter((b) => b.toLowerCase().includes(q))  return <ul>{shown.map((b) => <li key={b}>{b}</li>)}</ul>}
    export default function Bookshop() {  const [query, setQuery] = useState('')  return (    <main>      <SearchBox value={query} onChange={setQuery} />      <BookList query={query} />    </main>  )}
    State is private, so the list can only see the query if a parent of both owns it. Bookshop keeps it and passes it down twice: as value to show in the box, and as query to filter by. Changes travel up through onChange. Keeping a second useState in SearchBox as well is the usual slip: then there are two queries that drift apart.
  44. 10. Sharing state between components · Check: lift some state · Put in orderThe “show finished” checkbox lives in Settings, and now TodoList needs it too. Put the refactor in order.1. Find the closest parent of Settings and TodoList 2. Move the useState from Settings into that parent 3. Pass the value to TodoList and Settings as props 4. Pass a handler to Settings and call it from the checkboxFind the home first, then move the state, then wire it down. The handler is the piece people forget: without it, the checkbox shows the value but can no longer change it.
  45. 10. Sharing state between components · State belongs to a position in the tree · QuizA to-do row is being edited. You tick “show finished”, so the list re-renders with the same row in the same place. Is the half-typed draft kept?A. YesSame component, same position, same key: React keeps its state. If ticking the box had removed the row from the list, its state would be gone, and showing it again would start fresh.
  46. 11. Effects, and when not to use them · Check: when does it run · QuizThe component renders with filter going all, all, done. How many times does the effect run?B. TwiceIt runs after the first render (all), skips the second because filter is the same (React compares with Object.is), and runs after the third (done). In development, Strict Mode also runs it an extra time at first: see cleanups.
  47. 11. Effects, and when not to use them · Try it: keep the tab’s title in step · Code challengeAdd an effect that sets document.title to Inbox (3) while 3 are unread, and to just Inbox when none are. It should run again whenever unread changes.One solution:
    Solution
    import { useEffect, useState } from 'react'
    export default function Inbox() {  const [unread, setUnread] = useState(3)  useEffect(() => {    document.title = unread > 0 ? 'Inbox (' + unread + ')' : 'Inbox'  }, [unread])  return (    <div>      <p>{unread} unread</p>      <button onClick={() => setUnread(unread - 1)}>Read one</button>    </div>  )}
    The effect runs after React has updated the screen, and the dependency array [unread] makes it run again only when the count changed. Leaving the array out works here but runs after every render; [] would set the title once and never again, so it would stay at 3. Nothing needs undoing, so this effect has no cleanup.
  48. 11. Effects, and when not to use them · Check: a leaking listener · Find the bugAfter opening and closing this panel a few times, every key press runs the handler many times. Which line is the bug?Line 6: window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)Each time the effect runs it adds a listener and never removes one, so they pile up. Return () => window.removeEventListener('keydown', onKey) right after adding it. Every “add” in an effect needs its “remove”.
  49. 11. Effects, and when not to use them · You don’t need an effect to respond to an event · QuizWhich of these need an effect? Pick all.B. Keeping a connection to a live chat open while the chat is on screen; E. Starting a video player library on a DOM node when it appearsThe chat connection and the video library are outside systems tied to the component being on screen. Saving is an action: use the handler. The count is computed during render. Resetting a form is a job for a key.
  50. 12. Refs and the DOM · State or ref? · QuizWhat does the button show after three clicks?B. 0count.current really is 3 after three clicks, but nothing asked React to render, so the screen still shows 0. Anything shown on screen belongs in state. This is also why you shouldn’t read refs during render.
  51. 12. Refs and the DOM · Pass a ref to an element to reach its DOM node · Fill the blanksFocus the search box when the button is clicked.1: useRef, 2: ref, 3: currentMake the ref with useRef, attach it with the ref prop, and read the node from .current in the handler, after React has filled it in.
  52. 12. Refs and the DOM · Try it: put the cursor back · Code challengeMake a ref with useRef, attach it to the input, and in clearSearch call focus() on the node, so after Clear the Search recipes field has the cursor.One solution:
    Solution
    import { useRef, useState } from 'react'
    export default function SearchBar() {  const [query, setQuery] = useState('')  const input = useRef<HTMLInputElement>(null)
      function clearSearch() {    setQuery('')    input.current?.focus()  }
      return (    <div>      <label>        Search recipes{' '}        <input          ref={input}          value={query}          onChange={(e) => setQuery(e.target.value)}        />      </label>      <button onClick={clearSearch}>Clear</button>    </div>  )}
    useRef<HTMLInputElement>(null) makes an empty box; ref={input} asks React to put the input’s DOM node in input.current after the commit. The handler runs later, when the node is there, so input.current?.focus() works. Reading input.current during rendering is the mistake to avoid: on the first render it is still null.
  53. 12. Refs and the DOM · Reach for the DOM only for what React can’t do · Find the bugThis works once, then strange things happen when the list changes. Which line is the problem?Line 5: setTodos((todos) => todos.filter((t) => !t.done))It removes DOM nodes React thinks it owns. The to-dos are still in state, so React’s picture of the page and the real page now disagree. Change the data and let React update the DOM.
  54. 12. Refs and the DOM · Expose a small API with useImperativeHandle · QuizA parent wants a modal to be open or closed. Prop or ref?B. An open prop, with an onClose handlerWhether it is open is data, and data flows through props, where React can see it. A ref method would hide the state inside the child, so the parent couldn’t know or render from it. Save refs for true one-off actions like focus.
  55. 13. Reducers and context · useReducer: dispatch actions instead of setting state · QuizWhat does todosReducer return here?A. A new array with only the “Dog” to-doThe deleted branch returns todos.filter(…), a new array without id 1. Reducers never change the array they are given. Because a reducer is a plain function, you can test it exactly like this, without rendering anything.
  56. 13. Reducers and context · Try it: a cart reducer · Code challengeWrite cartReducer. added adds a line { name, qty: 1 }, or adds one to the qty of the line with that name; cleared empties the cart. Return new arrays and objects.One solution:
    Solution
    import { useReducer } from 'react'
    type Line = { name: string; qty: number }type Action = { type: 'added'; name: string } | { type: 'cleared' }
    function cartReducer(cart: Line[], action: Action): Line[] {  switch (action.type) {    case 'added': {      const line = cart.find((l) => l.name === action.name)      if (!line) return [...cart, { name: action.name, qty: 1 }]      return cart.map((l) =>        l === line ? { ...l, qty: l.qty + 1 } : l      )    }    case 'cleared':      return []  }}
    export default function Shop() {  const [cart, dispatch] = useReducer(cartReducer, [])  const add = (name: string) => dispatch({ type: 'added', name })  const clear = () => dispatch({ type: 'cleared' })  return (    <div>      <button onClick={() => add('Apple')}>Add an apple</button>      <button onClick={() => add('Pear')}>Add a pear</button>      <button onClick={clear}>Clear</button>      <ul>        {cart.map((l) => <li key={l.name}>{l.name} × {l.qty}</li>)}      </ul>    </div>  )}
    A reducer is a plain function from the cart and an action to the next cart, so the last test can call it without drawing anything. find tells a new item from one already there; map with a spread makes a new line for the one that changes. line.qty++ would pass the screen tests but fail the last one: changing the old state in place is what reducers must never do.
  57. 13. Reducers and context · useState or useReducer? · Fill the blanksAdd a “cleared” action that removes every finished to-do.1: filter, 2: !t.doneKeep the to-dos that are not done. filter returns a new array, so state isn’t mutated. Add | { type: 'cleared' } to the Action type too, and TypeScript will check every dispatch.
  58. 13. Reducers and context · Try it: read a context · Code challengeIn Greeting, read LangContext with useContext, and show Bonjour when it is fr, Hello otherwise. Don’t pass any new props.One solution:
    Solution
    import { createContext, useContext, useState } from 'react'
    type Lang = 'en' | 'fr'const LangContext = createContext<Lang>('en')
    function Greeting() {  const lang = useContext(LangContext)  return <p>{lang === 'fr' ? 'Bonjour' : 'Hello'}</p>}
    function Card() {  return <section><Greeting /><Greeting /></section>}
    export default function App() {  const [lang, setLang] = useState<Lang>('en')  return (    <LangContext value={lang}>      <button onClick={() => setLang('fr')}>Français</button>      <Card />    </LangContext>  )}
    useContext(LangContext) finds the nearest <LangContext value=…> above, however deep, so Card in the middle needs no props. When the provided value changes, every component reading it renders again. Without a provider above, useContext returns the default given to createContext, here en.
  59. 13. Reducers and context · Context is not free: use it for wide, slow-changing data · QuizWhich are good fits for context? Pick all.A. The colour theme; B. The signed-in userTheme and user are read in many places and change rarely. A search box used by one sibling is a case for lifting state. One level down is just a prop. Context is for data many distant components need.
  60. 14. Custom Hooks and fetching data · Use it like a built-in Hook · QuizTwo different components both call useStoredState('todos', []). Do they share one list in memory?B. No: each call has its own state; Hooks share logic, not stateA custom Hook is just a function. Each component that calls it gets its own useState inside. Both read the same saved text at first, but after that they drift apart. To share data, lift the state or use context.
  61. 14. Custom Hooks and fetching data · Rules for your own Hooks · Find the bugThis throws an error about Hooks when you click. Which line?Line 3: const [, setSaved] = useStoredState('backup', todos)A Hook called inside a handler runs only sometimes, so React can’t match it to the same slot every render. Call it at the top of the component and use the setter in the handler.
  62. 14. Custom Hooks and fetching data · Ignore answers that arrive too late · Find the bugWhich line should change so that a late answer to an old query is ignored?Line 4: if (!ignore) setRecipes(found)The cleanup runs when query changes, setting this run’s ignore to true. But the result is stored without checking it. Guard it: if (!ignore) setRecipes(found). Each effect run has its own ignore, so only the newest one is listened to.
  63. 14. Custom Hooks and fetching data · Wrap it in a Hook · QuizTyping “lasagne” fires seven requests, one per letter. What is the problem, and a common fix?B. Wasted requests; wait until typing pauses before searching (a debounce)The guard keeps the screen correct, but the server still does seven searches. A debounce waits, say, 300 ms after the last key press before using the text. Later you will meet useDeferredValue, which tackles the same feel from React’s side.
  64. 14. Custom Hooks and fetching data · Try it: a Hook that loads recipes · Code challengeFinish useRecipes: keep recipes and error in state, and in an effect call searchRecipes(query), storing the result or the error. Ignore answers that arrive after the query changed.One solution:
    Solution
    import { useEffect, useState } from 'react'
    const all = ['Pasta bake', 'Papaya salad', 'Lemon cake']const searchRecipes = (q: string) =>  new Promise<string[]>((resolve, reject) => {    setTimeout(() => {      if (q === 'error') reject(new Error('The kitchen is closed'))      resolve(all.filter((r) => r.toLowerCase().includes(q)))    }, 100)  })
    type Result = { recipes: string[] | null; error: Error | null }
    function useRecipes(query: string): Result {  const [recipes, setRecipes] = useState<string[] | null>(null)  const [error, setError] = useState<Error | null>(null)  useEffect(() => {    let ignore = false    setRecipes(null)    setError(null)    searchRecipes(query).then(      (found) => { if (!ignore) setRecipes(found) },      (err: Error) => { if (!ignore) setError(err) }    )    return () => { ignore = true }  }, [query])  return { recipes, error }}
    export default function Recipes({ query = 'pa' }) {  const { recipes, error } = useRecipes(query)  if (error) return <p role="alert">{error.message}</p>  if (!recipes) return <p>Loading…</p>  return <ul>{recipes.map((r) => <li key={r}>{r}</li>)}</ul>}
    Fetching keeps the screen in step with query, so it belongs in an effect with [query] as its dependency. .then(onDone, onFail) stores one of the two. The ignore flag is the race guard: when query changes, the cleanup marks the old request stale, and its answer is dropped. Calling searchRecipes straight in the Hook’s body, outside an effect, would start a new search on every render, and storing its answer would render again: a loop.
  65. 15. Accessible components · Everything you can use needs a name · Find the bugA screen reader announces one of these as just “checkbox”. Which line needs fixing?Line 2: <input type="checkbox" aria-label={todo.text} checked={todo.done}The text sits in a span that isn’t connected to the checkbox. Give it a name with aria-label={todo.text}, or better, wrap both in a <label> so clicking the text ticks the box too.
  66. 15. Accessible components · Try it: name every control · Code challengeWrap each checkbox and its text in a <label>, so the box is named after the to-do, and give the × button the name Delete plus the to-do’s text (Delete Buy milk).One solution:
    Solution
    type Todo = { id: number; text: string; done: boolean }
    function TodoRow({ todo }: { todo: Todo }) {  return (    <li>      <label>        <input type="checkbox" defaultChecked={todo.done} />        <span>{todo.text}</span>      </label>      <button type="button" aria-label={'Delete ' + todo.text}>        ×      </button>    </li>  )}
    export default function List() {  return (    <ul>      <TodoRow todo={{ id: 1, text: 'Buy milk', done: false }} />      <TodoRow todo={{ id: 2, text: 'Walk the dog', done: true }} />    </ul>  )}
    A label around an input names it with the label’s text, and clicking the text ticks the box too: a bigger target for everyone. A button showing only a symbol needs aria-label, which replaces its text as its name. “Delete” alone would pass a checker but leave a list of identical buttons; adding the to-do says which one.
  67. 15. Accessible components · ARIA describes what HTML can’t · QuizYou need a toggle between “all” and “done”. What is the best first choice?B. Two buttons with aria-pressedReal buttons are focusable and keyboard-operable already, and aria-pressed adds the one thing HTML lacks: on or off. The first option can work, but re-builds by hand what button gives for free, and is easy to get subtly wrong.
  68. 15. Accessible components · Try it: a disclosure that says if it’s open · Code challengeThe Ingredients button already shows and hides the list. Add aria-expanded so it tells a screen reader whether the list is open, and aria-controls pointing at the list’s id.One solution:
    Solution
    import { useState } from 'react'
    export default function Ingredients() {  const [open, setOpen] = useState(false)  return (    <section>      <button        type="button"        aria-expanded={open}        aria-controls="ingredients"        onClick={() => setOpen(!open)}      >        Ingredients      </button>      {open && (        <ul id="ingredients">          <li>Flour</li>          <li>Eggs</li>        </ul>      )}    </section>  )}
    A sighted person sees the list appear; a screen-reader user hears “Ingredients, button, collapsed”, then “expanded”, only if the button says so. React writes aria-expanded={false} as the text "false", which is what the attribute needs. The state is the one you already have: no new state, just one more place it shows.
  69. 15. Accessible components · Check: testing accessibility · Put in orderA cheap, effective routine for checking a new screen. Put it in order, from quickest to most thorough.1. Let the linter flag issues as you write (eslint-plugin-jsx-a11y) 2. Run an automated checker such as axe or Lighthouse in the browser 3. Put the mouse away and use the screen with only the keyboard 4. Use it with a screen reader (VoiceOver, NVDA or TalkBack)Automated tools catch perhaps a third of problems, cheaply. The keyboard pass finds unreachable controls and lost focus. A screen reader finds missing names and silent updates. Each step finds things the last one can’t.
  70. 15. Accessible components · Check: testing accessibility · QuizAn automated checker reports zero issues. Is the screen accessible?B. Not necessarily: tools can’t judge if names make sense or focus goes somewhere usefulA tool can see that a button has a name, not that “btn-3” is a useless one. Whether the flow makes sense needs a person.
  71. 16. Performance · Why a component renders · QuizYou type in AddTodo’s input, whose draft is state in App. Which components render?B. App, AddTodo and TodoList (and every row)The state lives in App, so App renders, and its children render with it. TodoList gets the same todos, but React doesn’t check props unless asked to. Rendering is usually cheap; it matters when the list is long or rows are heavy.
  72. 16. Performance · Try it: stop the chart redrawing · Code challengeMake typing in Note leave the chart alone, so it stays at Chart drawn 1 times. Either move the note’s state down into a NoteField component, or wrap SlowChart in memo.One solution:
    Solution
    import { memo, useRef, useState } from 'react'
    const SlowChart = memo(function SlowChart() {  // A render counter, for this lesson only: real code  // mustn't change a ref while rendering.  const draws = useRef(0)  draws.current++  return <p>Chart drawn {draws.current} times</p>})
    export default function Dashboard() {  const [note, setNote] = useState('')  return (    <div>      <label>        Note{' '}        <input value={note} onChange={(e) => setNote(e.target.value)} />      </label>      <SlowChart />    </div>  )}
    Typing sets state in Dashboard, and a render of a parent renders all its children. memo makes React compare SlowChart’s props first; it has none, so they never differ and the last result is reused. Moving the note into its own NoteField fixes it without memo: then the state change renders only NoteField, and Dashboard never runs. Prefer that when it fits: it can’t be undone by a new object in the props.
  73. 16. Performance · New objects and functions defeat memo · Find the bugTodoItem is wrapped in memo, yet every row still renders when the draft changes. Which line is the cause?Line 3: const toggle = useCallback((id: number) => dispatch({ type: 'toggled', id }), [dispatch])toggle is a new function on each render, so every row sees a changed onToggle. Wrap it: useCallback((id) => dispatch(…), [dispatch]). dispatch never changes, so toggle stays the same function.
  74. 16. Performance · The React Compiler memoises for you · QuizWhy does the compiler need components to be pure and state immutable?B. Memoising is only safe if the same inputs always mean the same output, and a change always means a new objectReusing an old result is only correct if nothing hidden could have changed it. Mutation hides changes inside “the same” object, and impure renders depend on things outside the inputs. Every rule in this course is also what makes automatic memoisation possible.
  75. 16. Performance · Check: a slow screen · Put in orderTyping in the search box of the recipe browser feels laggy. Put a sound approach in order.1. Reproduce it in a production build 2. Record typing in the Profiler and find what renders on each key press 3. Move state down or restructure so less of the tree depends on it 4. If it is still slow, memoise the expensive part or defer it 5. Measure again to confirm it helpedMeasure in production, because development mode adds checks that distort timing (to record a production build in the Profiler, use React’s profiling build). Structure fixes are free and remove whole subtrees from the work; memoisation and deferring come after. Always confirm with a second measurement.
  76. 17. Suspense and transitions · Try it: a boundary in the right place · Code challengeWrap BookList, and only BookList, in <Suspense> with the fallback <p>Loading books…</p>, so the heading shows at once and the list replaces the fallback when it is ready.One solution:
    Solution
    import { Suspense, use } from 'react'
    const fetchBooks = () =>  new Promise<string[]>((resolve) =>    setTimeout(() => resolve(['Dune', 'Emma', 'Beloved']), 200)  )const booksPromise = fetchBooks()
    function BookList() {  const books = use(booksPromise)  return <ul>{books.map((b) => <li key={b}>{b}</li>)}</ul>}
    export default function Shop() {  return (    <main>      <h1>Bookshop</h1>      <Suspense fallback={<p>Loading books…</p>}>        <BookList />      </Suspense>    </main>  )}
    When BookList suspends, React shows the fallback of the nearest Suspense above it, and everything outside that boundary stays. Put the boundary around the whole main and the heading disappears too; leave it out and nothing shows until the data is there. The last test waits 600 ms, not 200: once a fallback is shown, React keeps it at least 300 ms, so content doesn’t flash in and out.
  77. 17. Suspense and transitions · Where you put boundaries decides what the user sees · QuizIn the tree above, Reviews suspends but RecipeDetails is ready. What does the user see?B. The recipe details, with a reviews spinner belowOnly the nearest boundary above Reviews falls back. The outer one has everything it needs, so the details show. Nesting boundaries is how you let important content appear first.
  78. 17. Suspense and transitions · Check: which updates go in a transition · QuizWhich update must not be put in a transition?B. Setting a controlled input’s value as the user typesAn input’s value must update right away, or typed letters lag and can be lost. Keep the input’s state urgent; if something expensive depends on it, defer that part, as on the next slide.
  79. 17. Suspense and transitions · useDeferredValue lets a slow part lag behind · Fill the blanksLet the expensive chart lag behind the slider.1: useDeferredValue, 2: laggingThe slider uses size and stays smooth; the chart gets the deferred copy. For this to help, Chart must be wrapped in memo (or compiled by the React Compiler), so it can skip renders while lagging hasn’t changed.
  80. 18. Actions, use and the server · useActionState keeps the result of the last submit · QuizA function given to useActionState is written async (data: FormData) => …. What goes wrong?B. data is actually the previous result; the form data is the second argumentReact always calls it with (previousState, formData). Forgetting the first parameter is the most common mistake with this Hook, and TypeScript catches it if you type the parameters.
  81. 18. Actions, use and the server · useFormStatus reads the form a component is inside · Find the bugpending is always false here. Which line is the problem?Line 4It reads the status of a parent form, and this component renders the form rather than sitting inside it. Move the button into its own component, function Submit() { const { pending } = useFormStatus(); … }, and render <Submit /> inside the form.
  82. 18. Actions, use and the server · Try it: a like that lands at once · Code challengeUse useOptimistic(likes) so a click shows Like (11) at once, inside the Action, while the save runs. If the save fails, the button should settle back to Like (10), with Not saved shown.One solution:
    Solution
    import { startTransition, useOptimistic, useState } from 'react'
    const saveLikes = (n: number, fail: boolean) =>  new Promise<number>((resolve, reject) =>    setTimeout(() => (fail ? reject(new Error()) : resolve(n)), 100)  )
    export default function LikeButton({ fail = false }) {  const [likes, setLikes] = useState(10)  const [error, setError] = useState('')  const [shown, setShown] = useOptimistic(likes)  function like() {    startTransition(async () => {      setShown(likes + 1)      try {        const saved = await saveLikes(likes + 1, fail)        startTransition(() => setLikes(saved))      } catch {        startTransition(() => setError('Not saved'))      }    })  }  return (    <div>      <button onClick={like}>Like ({shown})</button>      <p>{error}</p>    </div>  )}
    useOptimistic(likes) returns likes itself until an Action sets a temporary value; while the Action runs, the button shows that instead. When the Action ends, the temporary value is dropped and the real state shows: 11 if the save worked and setLikes ran, 10 if it failed. No rollback code is needed. Calling setShown outside an Action does nothing useful, and React warns about it.
  83. 18. Actions, use and the server · Check: server or client · QuizIn a framework with Server Components, which parts of the recipe page must be Client Components? Pick all.B. A “save to favourites” button that shows a pending state; C. A servings slider that rescales ingredient amounts liveAnything with state, effects or event handlers runs in the browser: the button and the slider. Static content from the database is ideal for a Server Component: no code shipped, data read directly. Keep the client parts small, at the leaves of the tree.
  84. 19. Under the hood · Reconciliation: comparing the new tree with the old · QuizA to-do row is being edited. Then this condition flips. Is the draft kept?B. No: the type at that position changed from TodoItem to divAt the top position, React sees div where TodoItem was, so it destroys the old subtree, state included. Keep the structure the same and change props instead: <div className={highlighted ? 'glow' : ''}> around the row in both cases.
  85. 19. Under the hood · Check: the order of an update · Put in orderA state change updates a component that has a ref, a useLayoutEffect and a useEffect. Put what happens in order.1. React calls the component and compares the result 2. React changes the DOM 3. React attaches refs and runs useLayoutEffect 4. The browser paints the screen 5. React runs useEffectLayout effects run after the DOM changes but before the browser paints, so they can measure and adjust without a flicker. Normal effects run after paint, so they never delay what the user sees.
  86. 19. Under the hood · Hydration: taking over HTML drawn on the server · Find the bugThis causes a hydration mismatch warning. Which line?Line 2The server and the browser may be in different time zones, or render at different moments, so the text can differ. Render something stable first, then fill in the browser-only value in an effect, or show it only after mounting. The same applies to Math.random(), localStorage and window.
  87. 20. Mastery: building and reviewing a large app · Challenge: the update queue · Quizn is 0. After one click, what does the next render show?A. 84React processes the queue in order. n + 5 uses the snapshot: replace with 5. Then 5 + 1 = 6. Then replace with 42. Then 42 × 2 = 84. Plain values replace; updaters build on whatever came before them.
  88. 20. Mastery: building and reviewing a large app · Challenge: the update queue · QuizThe button is clicked once, then twice more within three seconds. What does the alert say the first time it shows?A. 0The timeout’s function belongs to the render where count was 0, and keeps that snapshot forever (it closes over it). Each later click’s alert shows its own render’s value: 1, then 2. To read the latest value later, keep it in a ref.
  89. 20. Mastery: building and reviewing a large app · Challenge: an endless loop · Find the bugThis component sends requests forever. Which line is the root cause?Line 3options is a new object every render, so the effect runs after every render. Each run sets new recipes, which renders, which makes a new options, and so on. Build the object inside the effect and depend on [query]. The race guard is correct but can’t stop the loop.
  90. 20. Mastery: building and reviewing a large app · Challenge: an endless loop · QuizA memo-wrapped TodoItem still renders on every key press in an unrelated input. Which could be the cause? Pick all.A. Its parent passes style={{ padding: 8 }}; B. It reads a context whose value is { todos, filter }, made fresh in each provider renderAn inline object is a new prop each render, so memo sees a change. A context whose value is a new object makes every reader render, and memo doesn’t stop context updates. A stable key and unchanged own state cause nothing.
  91. 20. Mastery: building and reviewing a large app · Challenge: kept or reset · QuizA user has typed into NoteEditor for to-do 1, then selects to-do 2. In which versions is the draft cleared? Pick all.B. B; D. DB changes the key, so React treats it as a new editor. In D the editor appears inside a list keyed by id, so each to-do has its own: to-do 1’s editor unmounts. A and C render the same type in the same place, so the state is kept, even though C looks like two components.
  92. 20. Mastery: building and reviewing a large app · Challenge: undo · Code challengeAdd Undo: keep a history of past lists in state, push the current list before each delete, and make Undo restore the last one, one step at a time. Undo is disabled when there is nothing to undo.One solution:
    Solution
    import { useState } from 'react'
    const start = ['Buy milk', 'Walk the dog', 'Water the plants']
    export default function Todos() {  const [todos, setTodos] = useState(start)  const [history, setHistory] = useState<string[][]>([])  function remove(text: string) {    setHistory([...history, todos])    setTodos(todos.filter((t) => t !== text))  }  function undo() {    setTodos(history[history.length - 1])    setHistory(history.slice(0, -1))  }  const rows = todos.map((t) => (    <li key={t}>      {t}{' '}      <button aria-label={'Delete ' + t} onClick={() => remove(t)}>        ×      </button>    </li>  ))  return (    <div>      <button onClick={undo} disabled={history.length === 0}>        Undo      </button>      <ul>{rows}</ul>    </div>  )}
    The history is a list of lists: before each delete, the current todos goes on the end, and Undo takes the last one back off. This only works because filter made a new array and never touched the old one; code that spliced the array in place would have “saved” the same array it then changed. Both setters run in one event, so React renders once with both. The same idea, past and present in one reducer, is how editors build undo and redo.
  93. 20. Mastery: building and reviewing a large app · Challenge: write a debounce Hook · Fill the blanksComplete a Hook that returns value only after it has stopped changing for ms milliseconds.1: useState, 2: setTimeout, 3: clearTimeout, 4: msEach new value starts a timer, and the cleanup cancels the previous one, so only a value that stays put for ms gets through. Both value and ms are read by the effect, so both are dependencies. Use it as const q = useDebounced(query, 300) before useRecipes(q).
  94. 20. Mastery: building and reviewing a large app · Challenge: a search that waits for you · Code challengeWrite useDebounced(value, ms): it returns value once it has stayed the same for ms milliseconds. Then typing Pea quickly calls search once, with Pea, and lists Pea soup.One solution:
    Solution
    import { useEffect, useState } from 'react'
    const all = ['Pasta bake', 'Pea soup', 'Lemon cake']const searchRecipes = (q: string) =>  new Promise<string[]>((resolve) =>    setTimeout(() => resolve(all.filter((r) => r.includes(q))), 50)  )
    function useDebounced<T>(value: T, ms: number): T {  const [settled, setSettled] = useState(value)  useEffect(() => {    const timer = setTimeout(() => setSettled(value), ms)    return () => clearTimeout(timer)  }, [value, ms])  return settled}
    export default function RecipeSearch({ search = searchRecipes }) {  const [query, setQuery] = useState('')  const settled = useDebounced(query, 300)  const [found, setFound] = useState<string[]>([])  useEffect(() => {    if (settled) search(settled).then(setFound)  }, [settled, search])  return (    <div>      <label>        Search recipes{' '}        <input          value={query}          onChange={(e) => setQuery(e.target.value)}        />      </label>      <ul>{found.map((r) => <li key={r}>{r}</li>)}</ul>    </div>  )}
    Each new value runs the effect again, and its cleanup clears the timer the last value started, so only a value that stays put for ms reaches setSettled. The search effect depends on settled, not query, so it runs once per pause rather than once per key. Forgetting the cleanup is the classic mistake: every key still searches, just 300 ms late. A real version would also keep the ignore guard from the fetching module, since a slow answer to an old pause can still arrive after a newer one.
  95. 20. Mastery: building and reviewing a large app · Challenge: async screens · QuizA recipe page reads use(recipePromise) from a cache, inside <Suspense>. Clicking “next recipe” calls setId inside startTransition. What does the user see while it loads?B. The current recipe stays, with isPending true, until the next one is readyIn a transition, React won’t replace content already on screen with a fallback; it keeps the old recipe and lets you show a pending hint. Without startTransition, the boundary would fall back to its spinner.
  96. 20. Mastery: building and reviewing a large app · Challenge: async screens · Put in orderYou are adding a “favourite” button to each recipe that saves to the server. Order a solid implementation.1. Write a Server Function or API call that saves the favourite 2. Wrap the button in a form whose action calls it 3. At the start of that action, show the change at once with useOptimistic 4. Catch a failed save in the action, report it with role="alert", and let the optimistic state roll backStart with the real operation, then wire it into React’s form Actions, then improve how it feels: the optimistic update makes the click land at once, and the error handling needs that action and that optimistic state to exist first. Each layer builds on the one before. A pending hint with useFormStatus fits in at any point once the form exists, which is why it isn’t a step here.
  97. 20. Mastery: building and reviewing a large app · Challenge: accessible tabs · Code challengePut the buttons in a role="tablist", give each role="tab", an id and aria-selected, and show only the current tab’s body in one role="tabpanel" with aria-labelledby set to its tab’s id.One solution:
    Solution
    import { useState } from 'react'
    const tabs = [  { id: 'food', label: 'Ingredients', body: 'Flour, eggs, milk' },  { id: 'method', label: 'Method', body: 'Whisk, rest, fry' }]
    export default function RecipeTabs() {  const [current, setCurrent] = useState(tabs[0])  return (    <div>      <div role="tablist" aria-label="Recipe">        {tabs.map((tab) => (          <button            key={tab.id}            role="tab"            id={tab.id + '-tab'}            aria-selected={tab === current}            onClick={() => setCurrent(tab)}          >            {tab.label}          </button>        ))}      </div>      <div role="tabpanel" aria-labelledby={current.id + '-tab'}>        {current.body}      </div>    </div>  )}
    Roles turn plain buttons into a widget a screen reader can describe: “Method, tab, 2 of 2, selected”. The panel takes its name from its tab through aria-labelledby, which is how the test finds it. Showing only the current body is ordinary conditional rendering. A complete version also moves between tabs with the arrow keys and gives only the selected tab tabIndex={0}; the tests here can’t press keys, but real users will.