The previous lesson ended with a commitment: to see the same Nómada Tasks screen —the task list with its assignee filter and its mark-as-done button— written four different ways, so that the comparison is real and not a feature catalog. We start with React, which is the most widespread and also the easiest to misunderstand, because its surface is deceptively small: a handful of functions and an odd syntax. The hard part of React is not learning it, it is understanding when your code runs again and why. This lesson is about exactly that. You will see what JSX is and what it turns into when compiled; how function components are written, how props are passed and how they compose; why lists need a key —and you will finally close the parallel with your reconcile() by data-id from 06-06—; how useState works and why state must be treated as immutable, where the immutable updates from 04-07 will shine; how React's events differ from your addEventListener; what useEffect is, and above all what it is not for, with its dependency array, its cleanup function —which is your destroy() from 09-03— and the classic mistake of using it to derive state; what useMemo, useCallback and useRef do, with 09-01's warning about not optimizing without measuring; how reusable logic is extracted into custom hooks, building useBoard and useAssigneeFilter; the difference between controlled and uncontrolled forms; how the virtual DOM really works; and how data is loaded with its three real problems —race conditions, cancellation and double execution. At the end, the complete task list in React, compared line by line with your board-view.js.

Contents

  1. What React is and what it is not
  2. Getting a project running
  3. JSX: what it is and what it turns into
  4. Function components
  5. props and composition
  6. Rendering lists and the key
  7. key: the same problem you solved in 06-06
  8. Conditional rendering
  9. State with useState
  10. Why state is treated as immutable
  11. The shape of the state matters more than the state
  12. Events in React and how they differ from addEventListener
  13. useEffect: what it is and what it is not
  14. The dependency array
  15. The cleanup function: your destroy()
  16. The mistake of deriving state with effects
  17. useMemo, useCallback and useRef
  18. Do not optimize without measuring
  19. Custom hooks: useBoard and useAssigneeFilter
  20. The rules of hooks and why they exist
  21. Controlled and uncontrolled forms
  22. The virtual DOM and reconciliation, properly
  23. The React compiler and server components
  24. Loading data with useEffect and its three problems
  25. Why production uses a data library
  26. The minimum ecosystem
  27. Nómada Tasks in React: the complete list
  28. Side-by-side comparison with board-view.js
  29. Common Mistakes and Tips
  30. Exercises
  31. Conclusion

  1. What React is and what it is not

React is a library for building user interfaces. The word "library" is deliberate and 10-01's distinction applies to the letter: React solves one problem —describing the screen as a function of the state and keeping it in sync— and it solves no others.

What React gives you:

  • A function component model.
  • A system for local state (useState) and effects (useEffect).
  • A reconciliation engine that translates your descriptions into minimal operations on the DOM.
  • A uniform event model across browsers.

What React does not give you and you have to choose yourself:

Need React includes What is used in practice
Routing Nothing React Router, TanStack Router, or the meta-framework's
HTTP requests Nothing fetch (your fetchJson from 07-03), TanStack Query
Global state Context, which is a transport mechanism, not a store Redux Toolkit, Zustand, Jotai (10-03)
Forms Nothing beyond state React Hook Form, or by hand
Styles Nothing Plain CSS, CSS modules, utilities, CSS-in-JS
Bundling Nothing Vite (the one you already know from 09-05)
Testing Nothing Jest or Vitest + Testing Library (08-03, 08-05)

That table is the operational definition of "unopinionated library". It has a practical consequence you will notice as soon as you look at somebody else's code: two React projects can look nothing alike. It also has an obvious advantage: your fetchJson with retries and AbortController from 07-03 can be used as it is, with no adapter at all, because React has no opinion about how you request data.

One point worth fixing from the start: React is not a language. Everything you are going to write is JavaScript, with a single syntax extension (JSX) that compiles down to ordinary function calls. The map, filter and reduce from 04-05, the destructuring from 04-07, the immutable spread, the promises from 05-06 and the modules from 05-04 are used in exactly the same way. That is one of the reasons for its popularity: almost everything you know transfers.

  1. Getting a project running

With Vite, which you have been using since 09-05, a new React project is created like this:

npm create vite@latest nomada-react -- --template react
cd nomada-react
npm install
npm run dev

What gets installed are two packages: react (the component engine, platform-independent) and react-dom (the one that knows how to paint in a browser). That separation exists because the same engine paints on native mobile with React Native.

The entry point is minimal:

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Three things to read carefully:

  • createRoot(node) takes an element of the real DOM —the same old <div id="root">— and turns it into React's territory. Outside that node, React touches nothing. You can mount React in a corner of an existing page; it is what a lot of people do when migrating.
  • .render(<App />) tells it what to paint inside.
  • <StrictMode> is a development wrapper that runs things twice on purpose to expose badly written effects. It does nothing in production. You are going to hate it in section 24 and then be grateful for it.

  1. JSX: what it is and what it turns into

This is JSX:

const element = <h2 className="task__title">Carpentry workshop quote</h2>;

It is not HTML inside JavaScript, nor a text template. It is syntactic sugar that the compiler (esbuild inside Vite, or Babel) turns into a function call. The above becomes, roughly, this:

// What the compiler produces (simplified)
import { jsx } from 'react/jsx-runtime';

const element = jsx('h2', {
  className: 'task__title',
  children: 'Carpentry workshop quote'
});

And what that call returns is an ordinary JavaScript object, not a DOM node:

{
  type: 'h2',
  props: { className: 'task__title', children: 'Carpentry workshop quote' },
  key: null
}

That object is the React element, the brick of the virtual DOM that 10-01 talked about. It is lightweight, it is read-only and it has not touched the DOM. Compare it with what your buildElement from js/view/dom.js did: that created a real node immediately; this describes one and decides later.

Understanding that JSX is function calls explains all of its rules, which otherwise look arbitrary:

Rule 1 · A component returns a single root element. Because return returns one value, not two. When you do not want an extra container, you use the fragment <>…</>:

return (
  <>
    <h2>{task.title}</h2>
    <p>{task.assignee}</p>
  </>
);

Rule 2 · Braces contain a JavaScript expression, not a statement. {task.title}, {hours * 2}, {isOverdue ? '⚠' : ''} work; an if or a for do not, because they produce no value. That is why lists are done with map and conditionals with the ternary operator or with &&.

Rule 3 · Attributes use the DOM property names, not the HTML ones. class is a reserved word in JavaScript, so you write className; labels' for is htmlFor; handlers are onClick, onInput, in camelCase. The data-* and aria-* attributes are the exception and are written just as they are, with hyphens, because they are not real properties.

<li className="task task--high" data-id={6} aria-label="Overdue task">
  <label htmlFor="filter">Assignee</label>
</li>

Rule 4 · {} interpolates values, and some values are not painted. null, undefined, false and true produce nothing. This is what makes section 8's conditional rendering possible, and also the cause of a classic bug: {tasks.length && <List/>} paints a 0 on screen when the list is empty, because 0 is painted.

Rule 5 · Interpolated content is escaped. {task.title} is inserted as text, never as HTML. It is the equivalent of your textContent versus innerHTML from 06-02, and it protects against injection in the same way. To insert real HTML you have to use a property with a deliberately awkward name (dangerouslySetInnerHTML), which is exactly the warning you want to have.

  1. Function components

A React component is a function that receives a props object and returns a description of a screen. Nothing more:

// src/components/TaskCard.jsx
export function TaskCard({ task }) {
  return (
    <li className="task">
      <h3 className="task__title">{task.title}</h3>
      <p className="task__meta">
        {task.assignee ?? 'unassigned'} · {task.estimatedHours} h
      </p>
    </li>
  );
}

Two conventions that are not negotiable:

  1. The name starts with a capital letter. It is not style: it is how the compiler distinguishes <TaskCard/> (your component) from <li> (an HTML tag). In lowercase, JSX generates the string 'taskcard' and React will try to create a nonexistent HTML element.
  2. The component must be pure with respect to its inputs. With the same props and the same state, the same output. It does not modify its props, does not write to external variables during rendering, does not touch the DOM directly. This is the f of UI = f(state), and the whole model depends on it holding.

Notice the destructuring in the signature: function TaskCard({ task }). It is the object destructuring in parameters from 04-07, and it is the dominant style because it documents which props the component receives just by reading the first line.

  1. props and composition

Props are the data a component receives from its parent. They flow downward and they are read-only: a component must not modify the object it receives.

// The parent decides which data and which behavior the child receives
<TaskCard task={task} onMarkDone={markDone} />

Three kinds of thing are passed, and it is worth telling them apart:

Kind of prop Example What for
Data task={task}, hours={45} What has to be painted
Functions onMarkDone={markDone} How to report upward that something has happened
Content children Composition: what goes inside

Function props are the mechanism for communicating upward, and they are the direct equivalent of your CustomEvents from 06-04, with one important difference: the CustomEvent travels through the DOM and anybody can listen to it; the function prop is an explicit contract between parent and child that you read in the signature.

Composition with children is the most underused piece:

// A generic container component
export function Panel({ title, children }) {
  return (
    <section className="panel">
      <h2 className="panel__title">{title}</h2>
      <div className="panel__body">{children}</div>
    </section>
  );
}

// Usage: whatever goes inside arrives as children
<Panel title="Open tasks">
  <TaskList tasks={visible} />
</Panel>

children is not magic: it is one more prop, which JSX fills with whatever you wrote between the opening and closing tags. And it provides a very powerful pattern: components that define the slot without knowing what goes inside, which is what web components' <slot>s do and, as you will see in 10-04, Vue's as well.

  1. Rendering lists and the key

A list is painted with map, the same one from 04-04:

export function TaskList({ tasks }) {
  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <TaskCard key={task.id} task={task} />
      ))}
    </ul>
  );
}

tasks.map(...) returns an array of React elements, and JSX knows how to paint an array by placing its elements one after another. Nothing new except one detail: key.

  1. key: the same problem you solved in 06-06

This is the moment to close the circle that opened in 06-06 and that 09-05 left noted.

When the state changes, React runs TaskList again and gets a new array of descriptions. Now it has to decide, for each element of the new array, which one of the old array it corresponds to. Without further information, the only available heuristic is position: the first with the first, the second with the second.

That heuristic fails in exactly the same cases that forced you to write reconcile with data-id. Suppose the canonical backlog and that task 2 is deleted:

Position Before After What React would do without key
0 Task 1 · Multipurpose room Task 1 · Multipurpose room Reuses the node. Correct
1 Task 2 · Signage Task 3 · Bookings website Reuses task 2's node and changes its text
2 Task 3 · Bookings website Task 4 · Inventory Reuses task 3's node and changes its text
3 Task 4 · Inventory Task 5 · Guide Reuses and changes
4 Task 5 · Guide Task 6 · Carpentry Reuses and changes
5 Task 6 · Carpentry Removes the last node

Visually the result is correct. But five nodes have changed data, and with them:

  • The focus, which was on task 3's "Start" button, ends up on a button that now shows task 4.
  • Any CSS transition in flight is applied to the wrong element.
  • The internal state of the child components —an open menu, a half-typed <input>— is attributed to the wrong task.
  • Five text updates are made when removing one node would have been enough.

It is word for word the analysis you did in 06-06's exercise. The solution is the same: a stable key that identifies the data, not its position.

<TaskCard key={task.id} task={task} />

With key, React builds a map of key → previous element —exactly the Map your reconcile builds with node.dataset.id— and pairs by key instead of by position. It reuses the ones that are still alive, moves the ones that changed place and removes the leftovers.

The three rules of key, which are the same as those of your data-id:

Rule Why
Stable: the same data always has the same key If it changes, React destroys and recreates: you lose focus, state and transitions
Unique among siblings: not globally Comparison only happens within the same list
Never the index if the list is reordered, filtered or allows removals The index is the position: using it is equivalent to having no key

A nuance about the index, because there is an enormous amount of misinformation about it: using the index as a key is not always a mistake. If the list is never reordered, never filtered, nothing is ever inserted or removed from the middle and its elements have no state of their own, the index is exactly as good as an id. The problem is that those four conditions stop holding the day somebody adds a filter, and then the bug is subtle, intermittent and hard to reproduce. The practical rule: if you have an id, use it.

And a warning that saves hours: key is not a prop. React consumes it and the component does not receive it. If TaskCard needs the id, it has to be passed too: <TaskCard key={t.id} task={t} /> works because the id is inside task.

  1. Conditional rendering

Three ways, with clear criteria for choosing:

// 1 · Ternary operator: when there are two alternatives
{task.status === 'done'
  ? <span className="badge">✓ Completed</span>
  : <button onClick={advance}>Start</button>}

// 2 · && : when either something is painted or nothing is
{task.isOverdue(TODAY) && <span className="warning">⚠ Overdue</span>}

// 3 · Early return: when the whole component changes
export function TaskList({ tasks }) {
  if (tasks.length === 0) {
    return <p className="empty">No task matches the filter.</p>;
  }
  return <ul className="task-list">{/* … */}</ul>;
}

The && trap deserves its own example because everybody falls for it once:

{tasks.length && <Summary tasks={tasks} />}

If tasks is empty, tasks.length is 0. The && operator returns 0, and 0 is not one of the values React ignores: it gets painted. A stray zero appears on the screen. The fix is to turn it into a real boolean:

{tasks.length > 0 && <Summary tasks={tasks} />}

It is a direct reminder of 01-07: falsy values are not all equivalent, and here the difference between 0 and false is literally visible on screen.

  1. State with useState

So far everything has been static. State is what makes the screen change.

import { useState } from 'react';

export function AssigneeFilter({ assignees, value, onChange }) {
  return (
    <label className="filter">
      Assignee:
      <select value={value ?? ''} onChange={(e) => onChange(e.target.value || null)}>
        <option value="">All</option>
        {assignees.map((name) => (
          <option key={name} value={name}>{name}</option>
        ))}
      </select>
    </label>
  );
}

That component has no state of its own: it receives it and reports upward. The state lives in the parent:

export function App() {
  const [assignee, setAssignee] = useState(null);
  // …
}

useState(initial) returns a two-position array that is destructured (04-06): the current value and the function to change it. Four essential points:

1 · The initial value is only used on the first render. On the following ones, React ignores the argument and returns the stored value. If the initial value is expensive to compute, you pass a function so that it only runs once:

const [board] = useState(() => createBoardFrom(BACKLOG));  // called ONCE
const [badBoard] = useState(createBoardFrom(BACKLOG));     // runs on EVERY render

This distinction is exactly the difference between passing a function and passing its result, from 03-02. With a board of 600 tasks, the second form builds 600 objects on every render and throws 599 of them away.

2 · Calling the update function requests a new render. It does not change the current variable: assignee still holds the same value until the end of this execution. It is beginner mistake number one:

function handleClick() {
  setAssignee('Iván');
  console.log(assignee);   // ← still null: the variable does NOT change here
}

It is a closure (03-04): assignee is a constant captured in this execution of the component function. The new value arrives in the next execution. Seeing it that way, rather than as "React is slow", removes the confusion at the root.

3 · Updates are batched. Several set* calls in a row produce a single render. And if the new value depends on the previous one, you have to use the function form:

setCount(count + 1);
setCount(count + 1);   // ← both read the SAME value: it increments by 1, not 2

setCount((n) => n + 1);
setCount((n) => n + 1);   // ← each one receives the previous result: it increments by 2

4 · If the new value is identical (Object.is) to the previous one, React does not re-render. Here is the reason immutability is not optional.

  1. Why state is treated as immutable

React compares the new value with the previous one using Object.is, which for objects and arrays compares reference identity, not content. It is the comparison from 04-08.

const tasks = [/* … */];
tasks[5].status = 'done';        // the object changes
setTasks(tasks);                 // ← same reference: React does NOT render

The screen does not update even though the data has changed. The mistake is not React's: it is that you handed it the same old array and asked it to notice the difference.

The solution is the one you have been using since 04-07: create new values instead of modifying the existing ones.

// Mark task 6 as done, immutably
setTasks((current) =>
  current.map((t) => (t.id === 6 ? { ...t, status: 'done' } : t))
);

Read it carefully, because it is the pattern you are going to write most in React:

  • map returns a new array: the reference changes, React detects the change.
  • For the tasks that are not number 6 it returns the same object: the reference is preserved. This is not a detail, it is a key optimization — it lets React (and React.memo) know that those cards have not changed.
  • For number 6, { ...t, status: 'done' } creates a new object with everything from before and the changed field. It is the spread from 04-07, exactly.

There is a real tension with Nómada Tasks here that is worth naming. Your Task class has a private #status and a changeStatus method that mutates the object while validating R6. That is good object-oriented design and it is incompatible with React's detection by reference. There are three honest ways out:

Option How When it fits
Plain data in React's state, classes outside The state holds object literals; the rules live in pure functions that receive and return objects The most common and the simplest
Immutable classes changeStatus returns a new instance instead of mutating Keeps the model, requires rewriting it
The board outside React, synchronized The Board stays yours and a version counter forces the render Not really recommended: two sources of truth

For the reimplementation in section 27 we will use the first, which is what most people do, and we will say so explicitly. It is a concrete example of what 10-01 called "the cost of lock-in": the framework has an opinion about the shape of your data.

  1. The shape of the state matters more than the state

A piece of advice that saves a lot of suffering and is learned late: almost every hard state problem in React is a badly modeled state problem.

Three practical rules:

Do not store what you can compute. This is wrong:

const [tasks, setTasks] = useState(BACKLOG);
const [openHours, setOpenHours] = useState(45);   // ← redundant and synced by hand

If openHours follows from tasks, storing it creates two sources of truth that have to be maintained by hand — 10-01's problem 1, reintroduced inside the framework. The correct version:

const [tasks, setTasks] = useState(BACKLOG);
const openHours = tasks
  .filter((t) => t.status !== 'done')
  .reduce((s, t) => s + t.estimatedHours, 0);   // recomputed on every render, and that is fine

Yes: it is recomputed on every render. And yes, that is fine. Adding six numbers, or six hundred, is irrelevant compared with the work of painting. Only if you measure that it hurts do you memoize it (section 17).

Store identifiers, not objects. If you have selectedTask as an object and the task changes in the list, you have a stale copy. Store selectedId and look the task up when painting.

Avoid impossible state. { loading: true, error: 'failure' } is a state that should not exist. Modeling it as a single variable with the values 'idle' | 'loading' | 'ready' | 'error' removes the impossible combination by construction. It is the same reasoning that led you to NEXT[status] in R6.

  1. Events in React and how they differ from addEventListener

In React, handlers are declared as props:

<button onClick={() => onMarkDone(task.id)}>Mark done</button>

It looks like nineties HTML onclick, but it works in a completely different way. A comparison table with what you know from 06-03 and 06-04:

Aspect addEventListener (06-03) React
Where it is registered On the specific node React uses delegation at the application root
How many listeners there are One per node One per event type, for the whole application
What the handler receives The native Event A SyntheticEvent that normalizes cross-browser differences
Access to the native event Direct e.nativeEvent
Canceling the default behavior e.preventDefault() or return false in some cases Only e.preventDefault()
Removing the listener removeEventListener or signal Automatic on unmount
Name of the typing event input onChange (which behaves like the native input)

Two practical consequences:

React already does delegation for you. The pattern you built in 06-04 with closest('[data-action]') so as not to register 600 listeners is exactly what React does internally. Writing onClick on each of the 600 cards does not create 600 DOM listeners: it creates one at the root. It is one of the things a framework gives you solved out of the box and that you solved by hand.

onChange is not the native change. It is one of React's few historical inconsistencies: onChange fires on every keystroke, like the native input, not when focus is lost like the DOM's change. If you come from plain JavaScript, it is the trap that most disconcerts.

  1. useEffect: what it is and what it is not

useEffect is React's least understood hook, and most of the problems come from one wrong idea: believing that it is "code that runs when something changes". It is not.

useEffect is for synchronizing your component with a system external to React.

"External system" means: the DOM outside your tree, a WebSocket, a setInterval, localStorage, the document title, a third-party library, the intersection observer. Things that exist outside the UI = f(state) model and that have to be switched on and off.

import { useEffect } from 'react';

function DocumentTitle({ pending }) {
  useEffect(() => {
    document.title = `Nómada Tasks (${pending})`;
  }, [pending]);

  return null;
}

The document.title is an external system: React does not manage it. Synchronizing it with the state is exactly the use case.

And now the list of what useEffect is NOT for, which is more useful:

Do not use it to… Use instead
Compute a value from the state or the props Compute it directly during the render
React to a user click The event handler
Transform data before painting it An expression, or useMemo if you measure that it hurts
Update state when a prop changes Rethink the state; almost always it is derived state
Load data in a production application A data library (section 25)

The rule that sums it all up: if the effect has nothing to switch off and touches nothing outside React, it probably should not be an effect.

  1. The dependency array

useEffect's second argument controls when it runs again:

useEffect(() => { /* … */ });                 // on EVERY render
useEffect(() => { /* … */ }, []);             // only on mount
useEffect(() => { /* … */ }, [id, filter]);   // on mount and whenever id or filter change

Dependencies are compared with Object.is, the same as in section 10. And out of that comes the most frequent problem of all:

function List({ tasks, filters }) {
  useEffect(() => {
    console.log('the filters have changed');
  }, [filters]);      // ← if the parent creates {assignee: null} on every render, this fires ALWAYS
}

An object literal written inside the parent component is a new object on every render. Its reference always changes, so the dependency always looks like it has changed. The solutions, in order of preference:

  1. Depend on primitive values: [filters.assignee, filters.text] instead of [filters]. It is almost always the right answer.
  2. Move the object's creation outside the component if it is constant.
  3. Memoize the object with useMemo in the parent. It is the last resort, not the first.

And a rule that admits no practical exceptions: declare every dependency the effect uses. The ESLint rule react-hooks/exhaustive-deps checks it, and —connecting with 08-02— it is one of the reasons a React project without ESLint is a bad idea. When you feel tempted to silence it, it almost always means the effect is badly conceived, not that the rule is wrong.

  1. The cleanup function: your destroy()

If the effect returns a function, React calls it before the effect's next execution and when the component unmounts. It is 10-01's lifecycle mechanism, and it is literally your destroy() from 09-03.

Compare. What you wrote in plain JavaScript:

// js/view/board-view.js — 09-03
export class BoardView {
  #controller = new AbortController();

  constructor(container) {
    this.channel = new BoardChannel();
    this.channel.subscribe(this.#onMessage);
    window.addEventListener('resize', this.#onResize,
                            { signal: this.#controller.signal });
  }

  destroy() {
    this.#controller.abort();       // removes ALL listeners registered with the signal
    this.channel.close();
    clearInterval(this.#clock);
  }
}

And the same thing in React:

function LiveBoard({ onMessage }) {
  useEffect(() => {
    const channel = new BoardChannel();
    channel.subscribe(onMessage);

    const controller = new AbortController();
    window.addEventListener('resize', onResize, { signal: controller.signal });

    const clock = setInterval(() => recalculateOverdue(), 60_000);

    return () => {                  // ← this is destroy()
      channel.close();
      controller.abort();
      clearInterval(clock);
    };
  }, [onMessage]);

  return null;
}

The content of the cleanup is identical. What changes is who calls it: in your version, somebody has to remember to invoke destroy() from the right place at the right time —and if reconcile removes a node, nobody says a word. In React, the call is guaranteed. That is the exact improvement: the work does not disappear, the forgetting does.

A detail that confuses people at first: the cleanup also runs between successive executions of the effect, not only on unmount. If onMessage changes, React first cleans up the old channel and then creates a new one. That is correct and it is what you want: the effect describes "while these dependencies hold these values, this subscription must exist".

  1. The mistake of deriving state with effects

This is React's most widespread antipattern. It looks like this:

// ❌ WRONG: an effect to compute something that follows from the state
function Panel({ tasks }) {
  const [openHours, setOpenHours] = useState(0);

  useEffect(() => {
    setOpenHours(
      tasks.filter((t) => t.status !== 'done')
           .reduce((s, t) => s + t.estimatedHours, 0)
    );
  }, [tasks]);

  return <p>{openHours} h open</p>;
}

Three things go wrong, and all of them are consequences, not opinions:

  1. There are two renders per change. The first paints the old value; the effect runs and changes the state; the second paints the correct one. The user may see the old number for a frame.
  2. There are two sources of truth. tasks and openHours can drift apart: it is 10-01's problem 1 reinvented inside the framework that came to solve it.
  3. It is more code and slower.

The correct version fits on one line:

// ✅ RIGHT: computed during the render
function Panel({ tasks }) {
  const openHours = tasks
    .filter((t) => t.status !== 'done')
    .reduce((s, t) => s + t.estimatedHours, 0);

  return <p>{openHours} h open</p>;
}

The rule, easy to memorize: if you can compute it during the render, compute it during the render. It is not a micro-optimization: it is avoiding the reintroduction of the very problem the declarative model came to eliminate.

  1. useMemo, useCallback and useRef

Three hooks that are constantly confused. Table first:

Hook What it keeps between renders When it is recomputed What it is really for
useMemo(fn, deps) The result of fn() When some dependency changes Avoiding an expensive computation, or keeping an object's reference stable
useCallback(fn, deps) The function itself When some dependency changes Keeping stable the reference of a function passed as a prop or a dependency
useRef(initial) A mutable { current } object Never; it always persists Storing something that must not trigger a render, or accessing a real DOM node

useMemo is exactly your version cache from 09-02, with the dependencies playing the role of the version counter:

// Your memoization from 09-02, in React syntax
const visible = useMemo(
  () => sortByPriority(tasks.filter((t) => assignee === null || t.assignee === assignee)),
  [tasks, assignee]
);

useCallback(fn, deps) is literally useMemo(() => fn, deps). It exists because the case is so frequent that it deserved a shortcut:

const markDone = useCallback((id) => {
  setTasks((current) => current.map((t) => (t.id === id ? { ...t, status: 'done' } : t)));
}, []);   // no dependencies: it uses setTasks' function form, it reads nothing from outside

Notice the empty array: it is possible precisely because setTasks receives a function instead of reading tasks from the closure. That pattern is what makes functions genuinely stable.

useRef is different from the other two, and it has two uses that do not resemble each other:

// Use 1: access a real DOM node (to measure, focus, play)
function SearchField() {
  const field = useRef(null);

  useEffect(() => { field.current.focus(); }, []);

  return <input ref={field} type="search" />;
}

// Use 2: store a mutable value that must NOT trigger a render
function Stopwatch() {
  const intervalId = useRef(null);
  // changing intervalId.current does not repaint anything
}

Use 1 is your escape hatch to the real DOM: when you need to measure with getBoundingClientRect (09-04), focus a field or integrate a library that expects a node. It is legitimate and sometimes indispensable, but every ref that modifies the DOM directly is a piece of screen that steps outside the UI = f(state) model.

  1. Do not optimize without measuring

Here it is worth being blunt, because this is where the most time is wasted in React and where 09-01 has the most to say.

useMemo and useCallback are not free. Each one stores a value and compares a dependency array on every render. Wrapping a trivial computation costs more than the computation:

// ❌ Absurd: comparing the dependency array costs more than the addition
const total = useMemo(() => a + b, [a, b]);

// ❌ Almost always useless: the child is not memoized, so it repaints anyway
const handleClick = useCallback(() => setOpen(true), []);

That second case is the most common and the most useless: useCallback only contributes something if the recipient of the function compares its props, that is, if it is wrapped in React.memo or if the function is a dependency of an effect. If not, you are paying for a stability nobody uses.

The correct procedure is 09-01's, with no shortcuts:

  1. Measure. The React DevTools Profiler records an interaction and shows which components rendered, how many times and how long they took. It is the Performance panel's equivalent for React.
  2. Find the expensive component. It is almost always one: a long list, a chart, a heavy computation.
  3. Check why it renders. The Profiler tells you which prop changed. Often the answer is "a new function on every render" and that is where useCallback does help.
  4. Apply the minimum optimization and measure again.

And the same warning as in 09-04: the optimization that pays best in long lists is not memoizing, it is not painting 600 rows. The virtualization you built is still the right answer, with libraries that implement it for you.

A note about the future that saves anxiety: the React compiler (section 23) is designed precisely to insert these memoizations automatically and make most of this manual work unnecessary.

  1. Custom hooks: useBoard and useAssigneeFilter

A custom hook is a function whose name starts with use and which calls other hooks. There is no more to the definition. Its value is enormous: it lets you extract stateful logic —not just computations— and reuse it across components.

It is the equivalent of what your util/ modules did with pure functions, but for logic that has state and a lifecycle.

// src/hooks/useBoard.js
import { useState, useCallback, useMemo } from 'react';
import { NEXT, LABEL } from '../domain/rules.js';

/**
 * The state of the Nómada Tasks board, with its operations.
 * The business rules (R5, R6) live in domain/rules.js, outside React.
 */
export function useBoard(initialTasks) {
  const [tasks, setTasks] = useState(initialTasks);

  const changeStatus = useCallback((id, target) => {
    setTasks((current) =>
      current.map((t) => {
        if (t.id !== id) return t;                 // same reference: it does not change
        if (NEXT[t.status] !== target) return t;   // R6: transition not allowed
        return { ...t, status: target };           // new object (04-07)
      })
    );
  }, []);

  const markDone = useCallback((id) => changeStatus(id, 'done'), [changeStatus]);

  const summary = useMemo(() => {
    const open = tasks.filter((t) => t.status !== 'done');
    return {
      total: tasks.length,
      totalHours: tasks.reduce((s, t) => s + t.estimatedHours, 0),
      openHours: open.reduce((s, t) => s + t.estimatedHours, 0),
      pending: tasks.filter((t) => t.status === 'pending').length
    };
  }, [tasks]);

  return { tasks, changeStatus, markDone, summary };
}
// src/hooks/useAssigneeFilter.js
import { useState, useMemo } from 'react';

/** The assignee filter: its state, the list of assignees and the result. */
export function useAssigneeFilter(tasks) {
  const [assignee, setAssignee] = useState(null);

  const assignees = useMemo(
    () => [...new Set(tasks.map((t) => t.assignee).filter(Boolean))].sort(),
    [tasks]
  );

  const visible = useMemo(
    () => (assignee === null ? tasks : tasks.filter((t) => t.assignee === assignee)),
    [tasks, assignee]
  );

  return { assignee, setAssignee, assignees, visible };
}

Five things to learn from these two hooks:

  1. They return an object, not an array. Arrays are convenient when there are two values (like useState); with five, an object is self-documenting.
  2. The business rules are outside. NEXT and R1–R10 live in domain/rules.js, in plain JavaScript. It is 10-01's discipline: the view belongs to the framework, the model is yours. If tomorrow you switch to Vue, this file is untouched.
  3. Every call to the hook creates its own state. Two components using useAssigneeFilter have two independent filters. A hook does not share state: it shares logic. Confusing this is misunderstanding number one about hooks.
  4. useMemo here does have a reason, and it is not the cost of the computation: it is keeping the reference stable for visible and assignees so that memoized child components do not repaint for no reason.
  5. new Set(...) from 04-05 removes duplicates and filter(Boolean) discards R8's nulls: two plain-JavaScript utilities used identically inside React.

  1. The rules of hooks and why they exist

Two rules, and an explanation that is almost never given:

  1. They are only called at the top level of a component or of another hook. Never inside an if, a loop, a try or a nested function.
  2. They are only called from React components or from custom hooks.

The reason for the first is purely mechanical. React does not know what your state variables are called: it keeps the values in a list and identifies them by call order. This component's first call to useState is position 0, the second is position 1, and so on.

// ❌ This breaks the correspondence
function Component({ show }) {
  if (show) {
    const [a, setA] = useState(1);   // ← sometimes it is position 0, sometimes it does not exist
  }
  const [b, setB] = useState(2);      // ← sometimes position 1, sometimes position 0
}

When show goes from true to false, b receives the value that belonged to a. There is no error, there is crossed data. That is why the rule is absolute and why the ESLint rule react-hooks/rules-of-hooks exists, and it must be enabled.

The correct way to have a conditional hook is to extract the conditional piece into its own component, which is sometimes painted and sometimes not.

  1. Controlled and uncontrolled forms

Two approaches, and the choice is made on a clear criterion.

Controlled: the field's value lives in React's state. The field only shows what the state says.

function TaskSearch({ onSearch }) {
  const [text, setText] = useState('');

  return (
    <input
      type="search"
      value={text}                                 // ← the source of truth is the state
      onChange={(e) => setText(e.target.value)}     // ← every keystroke updates the state
      placeholder="Search tasks"
    />
  );
}

Uncontrolled: the value lives in the DOM, as it always has, and is read when needed.

function TaskForm({ onCreate }) {
  const form = useRef(null);

  function submit(e) {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.currentTarget));   // 06-07
    onCreate(data);
    e.currentTarget.reset();
  }

  return (
    <form ref={form} onSubmit={submit}>
      <input name="title" required minLength={3} />
      <input name="estimatedHours" type="number" min={1} max={40} required />
      <button type="submit">Create task</button>
    </form>
  );
}

Notice the second one: FormData and Object.fromEntries are exactly those from 06-07, and the required, min and max attributes are the browser's native validation implementing rules R2 and R3. You do not need React for that, and using it is simpler, more accessible and faster.

Criterion Controlled Uncontrolled
Source of truth React's state The DOM
Renders while typing One per keystroke None
Live validation while typing Easy Hard
Disabling the button based on content Easy Hard
Formatting while typing (phone, amount) Easy Very hard
Large, simple form Costly Ideal
Integration with native HTML validation You lose part of it Complete

The practical rule: controlled when the interface has to react to what is being typed; uncontrolled when only the final value matters. A search box with live filtering is controlled (and with the debounce from 09-02, which is still necessary). A new-task form is perfectly fine uncontrolled.

  1. The virtual DOM and reconciliation, properly

You already know what a React element is (section 3) and why it needs a key (section 7). What is missing is putting the pieces of the full mechanism together, because it explains every strange behavior.

When a component's state changes, React does this:

graph TD
  S[The state changes] --> R["Render: the component's function<br/>and its children's functions run"]
  R --> A["New tree of elements"]
  A --> D["Reconciliation: compare<br/>the new tree with the previous one"]
  D --> L["List of minimal operations"]
  L --> C["Commit: apply them to the real DOM,<br/>run cleanups and effects"]

The three rules of the comparison algorithm, which are heuristics and not an optimal algorithm:

Rule 1 · Different types, new subtree. If there was a <div> in the same position and now there is a <section>, React compares nothing inside: it destroys the whole subtree and creates it again. The state of every component inside is lost. This explains a baffling bug:

// ❌ The component changes type depending on the condition: internal state is lost
{compact ? <div><List tasks={t}/></div> : <section><List tasks={t}/></section>}

Rule 2 · Same type, attributes are updated and the inside is compared. The DOM node is preserved; only the properties that differ change. It is what your paintCard(task, existing) does when it receives a node.

Rule 3 · In lists, pairing is by key; without key, by position. The whole of section 7.

What it costs, honestly:

  • The functions of every affected component run, even if the result is identical. With 600 cards, changing the filter runs 600 functions to find out that 594 have not changed. It is the virtual DOM's inherent cost that 10-01 announced: proportional to how much there is, not to how much has changed.
  • 600 descriptor objects are created that will be garbage in the next cycle. The collector from 09-03 has work to do.
  • In exchange, the operations on the real DOM are indeed minimal, which is where the expensive cost lies (style and layout recalculation, 09-04).

That is the exact trade: more work in JavaScript, less in the DOM. Since in your application you measured that the DOM was the bottleneck and not the JavaScript, the trade usually comes out in favor. But not always: with very large lists or very frequent updates, it shows, and that is why React.memo, useMemo and virtualization exist.

A note about concurrent rendering: modern React can interrupt a render in progress if something more urgent arrives (a keystroke), and pick it up again afterwards. That is what useTransition and useDeferredValue do, and they are the conceptual equivalent of your chunking with inBatches from 09-02: preventing a large update from blocking the main thread. We do not develop it here, but you will recognize the problem.

  1. The React compiler and server components

Two important developments worth knowing about so you do not get confused reading current code, without developing them.

The React compiler. It analyzes your components during the build and automatically inserts the memoizations that today are written by hand with useMemo, useCallback and React.memo. If it works well, section 18 becomes a historical anecdote: you write simple code and the compiler optimizes it, which is exactly 10-01's family 3 approach applied to a virtual DOM framework. The condition for it to work is that your components be pure —what section 4 asked for—, so the discipline you are learning now is what enables tomorrow's optimization.

Server components. Components that run only on the server and send the browser the result, not their code. Advantages: zero JavaScript downloaded for the parts that are not interactive, and direct database access without going through an API. They imply a new distinction between server and client components, with rules of their own about what each can do, and in practice they require a meta-framework like Next.js. It is a big and moving topic; the fair thing here is to know that it exists and that it addresses the downloaded-weight problem, which 10-06 will pick up again when discussing server rendering.

  1. Loading data with useEffect and its three problems

This is the most useful section of the lesson for the real world. The "obvious" way to load data is this, and it has three serious flaws:

// ❌ The naive version, with three problems
function RemoteList({ assignee }) {
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    listTasks({ assignee })
      .then((data) => { setTasks(data); setLoading(false); });
  }, [assignee]);

  if (loading) return <p>Loading…</p>;
  return <TaskList tasks={tasks} />;
}

Problem 1 · Race condition. Marta changes the filter from "Iván" to "Lucía" quickly. Two requests are fired. Iván's takes 900 ms; Lucía's, 200 ms. Lucía's comes back first and paints her tasks; afterwards Iván's comes back and overwrites them. The screen shows Iván's tasks with the filter set to "Lucía". It is a real bug, intermittent and very hard to reproduce.

Problem 2 · No cancellation. If the component unmounts while the request is in flight, setTasks is called on a component that no longer exists: wasted work and, with heavy resources, one of 09-03's leaks.

Problem 3 · Double execution in strict mode. In development, <StrictMode> mounts, unmounts and mounts each component again on purpose. You will see two requests in the Network tab. It is not a React bug: it is a detector for effects that do not clean up properly. If your effect is correct, the double execution is harmless.

The correct version uses what you already know from 07-03:

// ✅ With cancellation and protection against races
function RemoteList({ assignee }) {
  const [state, setState] = useState({ phase: 'loading', tasks: [], error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState((s) => ({ ...s, phase: 'loading' }));

    listTasks({ assignee, signal: controller.signal })
      .then((tasks) => setState({ phase: 'ready', tasks, error: null }))
      .catch((error) => {
        if (error.name === 'AbortError') return;      // expected cancellation: not a failure
        setState({ phase: 'error', tasks: [], error });
      });

    return () => controller.abort();                   // ← cleanup: kills the previous request
  }, [assignee]);

  if (state.phase === 'loading') return <p role="status">Loading tasks…</p>;
  if (state.phase === 'error') return <p role="alert">Could not load: {state.error.message}</p>;
  return <TaskList tasks={state.tasks} />;
}

The three fixes, one per problem:

  1. The race disappears because the cleanup aborts the previous request before firing the new one. Iván's response never reaches then because its promise is rejected with AbortError.
  2. Cancellation comes from the very same AbortController from 07-03, passed to fetchJson as signal.
  3. The double execution no longer bothers you: the first request is aborted on unmount and only the second one counts.

And notice the state: a single variable with a phase, instead of three independent booleans. It is the impossible-state rule from section 11.

  1. Why production uses a data library

The code above is correct and it is twenty lines. Now multiply it by the fifteen screens of a real application and questions appear that this code does not answer:

  • If two components request the same tasks at once, are two requests made?
  • When returning to a screen visited ten seconds ago, is what was there shown while it refreshes, or a loading screen?
  • When Marta comes back to the tab after an hour, does the data refresh?
  • When a task is created, who invalidates the list so it appears?
  • If the network fails, is it retried? How many times?
  • With optimistic UI (07-03), who rolls back if the server rejects it?

Answering all that by hand, on every screen, is writing a server-state cache. And that already exists: TanStack Query is the most widely used in React (with equivalents in Vue, Angular and Svelte). The concept, which is what matters here:

// Presented as a concept, not as a tutorial
const { data: tasks, isPending, error } = useQuery({
  queryKey: ['tasks', assignee],             // this query's identity in the cache
  queryFn: ({ signal }) => listTasks({ assignee, signal })
});

What it contributes, connecting directly with 10-01's section 15:

It contributes What it solves
Cache by key Two components with the same queryKey share one request
Stale data while revalidating What was there is shown and refreshed in the background: no loading screens when going back
Automatic refresh On window focus, on reconnect, by interval
Retries With exponential backoff, like your withRetries from 07-03
Cancellation It passes the signal automatically
Invalidation After creating a task, ['tasks'] is marked as stale and reloads on its own
Optimistic mutations With automatic rollback if it fails

What matters is not the library: it is 10-01's idea that server state is a different problem and deserves a tool of its own. As soon as you accept that, the state left over for React —or for Redux in 10-03— is far smaller than it looked.

  1. The minimum ecosystem

What a real React project needs, beyond React:

Piece Usual option What you already know
Bundling Vite 09-05, as it is
Routing React Router / TanStack Router / meta-framework The History API from 07-06 and your router.js
Remote data TanStack Query over your fetchJson 07-02, 07-03
Global state Redux Toolkit, Zustand, Jotai 10-03
Forms React Hook Form, or FormData by hand 06-07
Quality ESLint with react-hooks, Prettier 08-02
Testing Vitest or Jest + React Testing Library 08-03, 08-05
End to end Cypress or Playwright 08-06

It is worth pausing on testing, because you learn nothing new here: Testing Library works the same. In 08-05 you wrote tests that rendered the view, queried by role and simulated clicks. In React it is identical:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('filtering by Lucía leaves only her task', async () => {
  render(<App initialTasks={BACKLOG} />);

  expect(screen.getAllByRole('listitem')).toHaveLength(6);

  await userEvent.selectOptions(screen.getByLabelText(/assignee/i), 'Lucía');

  expect(screen.getAllByRole('listitem')).toHaveLength(1);
  expect(screen.getByText('Update the bookings website')).toBeInTheDocument();
});

Queries by role, userEvent, the philosophy of testing what the user sees and not the internal details: all just like 08-05. It is one of the things that transfer best between plain JavaScript and any framework, and one more reason for having learned the underlying layer first.

  1. Nómada Tasks in React: the complete list

Here is the complete reimplementation of the target screen: the task list, with an assignee filter and a mark-as-done button. Four component files, two hooks and a domain file in plain JavaScript.

First the domain, which is not React and would not change if you changed framework:

// src/domain/rules.js — plain JavaScript, no dependencies
export const NEXT = Object.freeze({
  pending: 'in-progress',
  'in-progress': 'done',
  done: null
});

export const LABEL = Object.freeze({
  pending: 'Start',
  'in-progress': 'Mark done',
  done: 'Completed'
});

export const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });

export const TODAY = '2026-09-20';

/** R10: overdue = due date in the past and status other than 'done'. */
export function isOverdue(task, today = TODAY) {
  return task.status !== 'done' && task.dueDate < today;
}

export function openHours(tasks) {
  return tasks.filter((t) => t.status !== 'done')
              .reduce((sum, t) => sum + t.estimatedHours, 0);
}

Now the card:

// src/components/TaskCard.jsx
import { memo } from 'react';
import { NEXT, LABEL, isOverdue } from '../domain/rules.js';

export const TaskCard = memo(function TaskCard({ task, onAdvance }) {
  const overdue = isOverdue(task);
  const next = NEXT[task.status];

  return (
    <li
      className={`task task--${task.priority}
                  ${task.status === 'done' ? 'task--done' : ''}
                  ${overdue ? 'task--overdue' : ''}`}
      data-id={task.id}
    >
      <h3 className="task__title">{task.title}</h3>

      <p className="task__meta">
        {task.assignee ?? 'unassigned'} · {task.estimatedHours} h
        {overdue && <span className="task__warning"> · ⚠ overdue</span>}
      </p>

      <ul className="task__tags">
        {task.tags.map((tag) => (
          <li key={tag} className="tag">{tag}</li>
        ))}
      </ul>

      <button
        type="button"
        disabled={next === null}
        onClick={() => onAdvance(task.id, next)}
        aria-label={`${LABEL[task.status]}: ${task.title}`}
      >
        {LABEL[task.status]}
      </button>
    </li>
  );
});

Points to underline:

  • memo makes the card re-render only if its props change by reference. Here it does make sense, because useBoard returns the same task object for the ones that have not changed (thanks to the immutable map from section 19) and onAdvance is stable thanks to useCallback. Without those two conditions, memo would be useless.
  • data-id is kept. React does not need it —that is what key is for— but your Cypress tests from 08-06 do, and it keeps the DOM readable.
  • The tag list has its own key: the tag's text, which is unique within the task by R9 (lowercase and no duplicates). That rule, which you wrote for data cleanliness, turns out to be exactly what makes the key valid.
  • The button is disabled when NEXT[status] is null, which is R6 applied in the view.

The filter:

// src/components/AssigneeFilter.jsx
export function AssigneeFilter({ assignees, value, onChange }) {
  return (
    <label className="filter" htmlFor="assignee-filter">
      Assignee:
      <select
        id="assignee-filter"
        value={value ?? ''}
        onChange={(e) => onChange(e.target.value || null)}
      >
        <option value="">All</option>
        {assignees.map((name) => (
          <option key={name} value={name}>{name}</option>
        ))}
      </select>
    </label>
  );
}

An important detail: value={value ?? ''} and e.target.value || null. The state uses null for "no filter" —consistent with R8, which forbids the empty string— but the DOM only understands strings. The conversion is done at the boundary, in both directions.

The list:

// src/components/TaskList.jsx
import { TaskCard } from './TaskCard.jsx';

export function TaskList({ tasks, onAdvance }) {
  if (tasks.length === 0) {
    return <p className="empty-list">No task matches the filter.</p>;
  }

  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <TaskCard key={task.id} task={task} onAdvance={onAdvance} />
      ))}
    </ul>
  );
}

And the application that puts it all together:

// src/App.jsx
import { useBoard } from './hooks/useBoard.js';
import { useAssigneeFilter } from './hooks/useAssigneeFilter.js';
import { AssigneeFilter } from './components/AssigneeFilter.jsx';
import { TaskList } from './components/TaskList.jsx';
import { openHours } from './domain/rules.js';

export default function App({ initialTasks }) {
  const { tasks, changeStatus, summary } = useBoard(initialTasks);
  const { assignee, setAssignee, assignees, visible } = useAssigneeFilter(tasks);

  return (
    <main className="board">
      <header className="board__header">
        <h1>Nómada Tasks</h1>
        <AssigneeFilter
          assignees={assignees}
          value={assignee}
          onChange={setAssignee}
        />
        <p className="board__summary">
          {visible.length} of {summary.total} tasks · {openHours(visible)} h open
        </p>
      </header>

      <TaskList tasks={visible} onAdvance={changeStatus} />
    </main>
  );
}

Check the canonical numbers: with no filter, 6 of 6 tasks and 45 h open. With "Iván", 3 of 6 and 25 h. With "Lucía", 1 of 6 and 14 h. With "Marta", 2 of 6 and 6 h open (the inventory one is done and does not count). And on pressing "Start" on Carpentry workshop quote, the card changes status, stops being overdue as soon as it reaches done, and the open hours drop to 40 — without anybody having written a single line that updates the summary. That is the declarative model at work.

  1. Side-by-side comparison with board-view.js

Now the honest comparison with what you already had.

Concept Plain JavaScript (Nómada Tasks) React
Describing the screen paintCard(task, existing, today) + <template> <TaskCard task={t}/>
Identity in lists data-id + reconcile() (20 lines written by you) key={t.id} (one property)
Repainting Calling render() by hand Calling setState; the render is automatic
Local state of a piece No natural place: dataset or an external Map useState inside the component
Listening for events Delegation with closest('[data-action]') onClick on each element (React delegates internally)
Communicating upward CustomEvent + EVENTS A function prop
Cleanup destroy() + AbortController, invoked by hand The effect's return, invoked by React
Cache of derived values Version cache with #version useMemo with dependencies
Styles Global classes in styles.css Global classes, CSS modules or similar
Reusing stateful logic Classes or closures Custom hooks

And the quantitative comparison of the same screen —list with filter and advance button—, with this section's files against the equivalents in your project:

Metric Plain JavaScript React
Lines of view code ~210 (dom.js + card.js + board-view.js + controller.js + <template>) ~130 (4 components + 2 hooks)
Lines of infrastructure written by you ~60 (reconcile, buildElement, $, $$, delegation) 0
Lines of domain (reusable in any case) ~40 ~40 (the same ones)
Production dependencies 0 2 (react, react-dom)
JavaScript downloaded (compressed, approx.) 58.3 kB for the whole application +45 kB for the engine alone
Files to touch to add a piece of data to the card 2 (<template> and card.js) 1 (TaskCard.jsx)
Concepts you must know to read it DOM, events, ES modules All of the above plus JSX, hooks, the rules of hooks, reconciliation

What has been gained, with no embellishment:

  • 60 lines of infrastructure disappear, lines that also had to be maintained and tested.
  • Per-component local state finally has somewhere to live.
  • Cleanup is guaranteed, not entrusted to memory.
  • Each component is a readable unit, with its contract on the first line.
  • Reconciliation works for nested trees, not just for flat lists of direct children — which is where your reconcile fell short.

What has been lost, with the same frankness:

  • 45 kB of engine downloaded before seeing anything. In an application of this scale, it is more than all the logic.
  • A mandatory build step: without a compiler there is no JSX.
  • New concepts to learn and debug: why it runs twice, why this dependency always changes, why this does not update.
  • Fine control over the DOM: your virtualization from 09-04, your write batching and your measurements with requestAnimationFrame now have to be done through React, with refs and libraries, instead of directly.
  • Independence: 60% of this code is only good inside React.

The honest conclusion for Nómada Tasks: with six tasks, one screen and one developer, React does not pay off. With five screens, four people and thirty reusable components, it clearly does. And that change of answer according to context —not an absolute winner— is exactly what 10-06 is going to formalize.

Common Mistakes and Tips

Using the array index as the key. It works until the day the list is filtered, reordered or has items removed from the middle; then it produces subtle focus and state bugs. If you have an id, use it. It is the same mistake you would have made by using the position in your reconcile.

Mutating the state. tasks.push(newTask) or task.status = 'done' do not trigger a render, because the reference does not change. Use spread and map as in 04-07. If the state is a deeply nested object, that is a sign it is badly modeled.

Expecting the state to change immediately after setState. It does not: the current variable is a constant captured in this execution. The new value arrives in the next render. If you need the previous value to compute the new one, use the function form: setCount((n) => n + 1).

Putting object literals in the dependency array. [{ assignee }] is a new object on every render and the effect fires every time. Depend on primitives: [assignee].

Silencing exhaustive-deps. It is the rule that stops an effect from reading stale values. When it gets in the way, the problem is almost always the effect's design. Disabling it turns a warning into an intermittent bug.

Using useEffect to derive state. Two renders, two sources of truth and one frame with the old value. Compute during the render; memoize only if you measure that it hurts.

Memoizing everything "just in case". useMemo and useCallback have a cost and clutter the code. Without React.memo on the recipient, useCallback is useless. Measure with the Profiler first, as in 09-01.

Panicking about the double execution in development. <StrictMode> mounts and unmounts on purpose to expose effects that do not clean up. If your effect is well written, it is harmless. If it genuinely bothers you, there is something to fix.

Loading data without AbortController. It is section 24's race condition, and it produces bugs that only show up on a slow network. Everything from 07-03 is still as necessary as before.

Tip: keep business logic outside the components. domain/rules.js is plain JavaScript and is tested with Jest without rendering anything. It is 10-01's discipline and it is what makes the framework choice reversible.

Tip: start without memoization and without libraries. useState, props and direct computation go much further than they look. Add complexity when the Profiler or real pain justifies it.

Exercises

Exercise 1 · Add the summary by assignee

Starting from section 27's code, add an <AssigneeSummary> component that shows, for each person on the team, how many open tasks they have and how many hours those add up to. It must respect the canonical numbers: Iván 3 tasks / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h.

Requirements:

  • The computation is done during the render, not in an effect.
  • Use Object.groupBy or reduce, as in 04-05.
  • The component receives the tasks through props and has no state of its own.
  • Add a correct key to the list and justify your choice.

An additional question: should this component show the total for all the tasks or only for the visible ones after the filter? Justify it.

Exercise 2 · Hunt down the race-condition bug

This component has three different flaws. Find them, explain when each one shows up and write the corrected version.

function TaskDetail({ id }) {
  const [task, setTask] = useState(null);
  const [comments, setComments] = useState([]);

  useEffect(() => {
    fetch(`/api/tasks/${id}`)
      .then((r) => r.json())
      .then(setTask);
  }, []);

  useEffect(() => {
    if (task) {
      setComments(task.comments.filter((c) => !c.deleted));
    }
  }, [task]);

  return (
    <article>
      <h2>{task?.title}</h2>
      {comments.map((c, i) => <p key={i}>{c.text}</p>)}
    </article>
  );
}

Exercise 3 · The useDebounce hook and the search box

In 09-02 you wrote a debounce function in js/util/time.js so that the search box would not filter on every keystroke. Now do it the React way:

  1. Write a useDebounce(value, delay) hook that returns the delayed value, using useState and useEffect with its cleanup.
  2. Use it in a <TaskSearch> component with a controlled <input> that filters the list by title.
  3. Explain why the <input> must use the immediate value and the filter the delayed one, and what would happen if the delayed one were used in both places.
  4. Why does this hook absolutely require the cleanup function? What would happen without it?

Solutions

Solution 1

// src/components/AssigneeSummary.jsx
export function AssigneeSummary({ tasks }) {
  // Computed during the render: no effects, no state.
  const byPerson = tasks
    .filter((t) => t.status !== 'done')            // open ones only
    .reduce((acc, t) => {
      const name = t.assignee ?? 'unassigned';     // R8: null, never ''
      const previous = acc[name] ?? { tasks: 0, hours: 0 };
      acc[name] = {
        tasks: previous.tasks + 1,
        hours: previous.hours + t.estimatedHours
      };
      return acc;
    }, {});

  const rows = Object.entries(byPerson).sort(([a], [b]) => a.localeCompare(b, 'en'));

  return (
    <ul className="assignee-summary">
      {rows.map(([name, { tasks: n, hours }]) => (
        <li key={name}>
          <strong>{name}</strong>: {n} {n === 1 ? 'task' : 'tasks'} · {hours} h
        </li>
      ))}
    </ul>
  );
}

The key: the assignee's name. It is unique within this list (it is the key of the grouped object) and it is stable: if Iván goes from three tasks to two, his row keeps the same node and does not flicker. The index would be a bad idea because the list is reordered as people appear and disappear while filtering.

With the canonical backlog, with no filter: Iván 3 tasks / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h. A total of 5 open tasks and 45 h, which adds up (the sixth, Marta's Screen-printing ink inventory, is done and does not count).

Totals or visible ones? It must receive all the tasks, not the visible ones. The reason is one of meaning: the summary by assignee exists to answer "how is the workshop's workload distributed?", and that question does not depend on what Marta happens to be looking at right now. If it received the visible ones, filtering by Iván would make the summary show only Iván, which is tautological and useless. It is a good example of the fact that which props you pass is a product decision, not a technical one:

<AssigneeSummary tasks={tasks} />     {/* all of them */}
<TaskList tasks={visible} … />        {/* filtered */}

Solution 2

Flaw 1 · A missing dependency ([] instead of [id]). It shows up when the user navigates from one task to another without the component unmounting: the first task keeps being displayed forever, because the effect does not run again. It is exactly what exhaustive-deps catches.

Flaw 2 · No cancellation and no error handling. Two manifestations: section 24's race condition if you switch tasks quickly on a slow network, and a silent failure if fetch rejects or if the server responds 404 (remember from 07-02 that fetch does not reject on a 404: r.json() will fail when it tries to parse the response).

Flaw 3 · Derived state with an effect (the second useEffect) and a key by index in the comments. comments follows from task, so it is computed during the render. And using the index as a key means that, when a comment in the middle is deleted, all the following ones inherit nodes that are not theirs.

Corrected version:

function TaskDetail({ id }) {
  const [state, setState] = useState({ phase: 'loading', task: null, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ phase: 'loading', task: null, error: null });

    fetchJson(`/api/tasks/${id}`, { signal: controller.signal })   // 07-03: throws ApiError
      .then((task) => setState({ phase: 'ready', task, error: null }))
      .catch((error) => {
        if (error.name === 'AbortError') return;
        setState({ phase: 'error', task: null, error });
      });

    return () => controller.abort();
  }, [id]);                                    // ← flaw 1 fixed

  if (state.phase === 'loading') return <p role="status">Loading…</p>;
  if (state.phase === 'error') return <p role="alert">{state.error.message}</p>;

  // ← flaw 3 fixed: derived during the render
  const comments = state.task.comments.filter((c) => !c.deleted);

  return (
    <article>
      <h2>{state.task.title}</h2>
      {comments.map((c) => <p key={c.id}>{c.text}</p>)}   {/* stable key */}
    </article>
  );
}

An additional note: fetchJson is your function from 07-03, which already checks response.ok and throws ApiError. Reusing it inside React with no adapter is the best demonstration that logic which does not depend on the framework survives the framework.

Solution 3

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

/**
 * Returns `value` with a delay: it only updates when `value` stops
 * changing for `delay` milliseconds.
 */
export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);      // ← indispensable
  }, [value, delay]);

  return debounced;
}
// src/components/TaskSearch.jsx
import { useState, useMemo } from 'react';
import { useDebounce } from '../hooks/useDebounce.js';
import { TaskList } from './TaskList.jsx';

export function TaskSearch({ tasks, onAdvance }) {
  const [text, setText] = useState('');
  const debouncedText = useDebounce(text, 300);

  const visible = useMemo(() => {
    const t = debouncedText.trim().toLowerCase();
    return t === '' ? tasks : tasks.filter((x) => x.title.toLowerCase().includes(t));
  }, [tasks, debouncedText]);

  return (
    <>
      <label htmlFor="search">Search tasks</label>
      <input
        id="search"
        type="search"
        value={text}                                 // ← IMMEDIATE value
        onChange={(e) => setText(e.target.value)}
      />
      <TaskList tasks={visible} onAdvance={onAdvance} />
    </>
  );
}

Point 3 · Why the <input> uses the immediate value. A controlled field shows exactly what its value says. If you passed it debouncedText, the letters would appear 300 ms after being typed: it would feel like a broken keyboard, and deleting quickly would produce cursor jumps. It is exactly the problem 09-02 described when distinguishing between what is seen (must be immediate) and what costs (can be delayed). The filter, which walks the tasks, is what gets delayed.

Point 4 · Why the cleanup is indispensable. Without clearTimeout, every keystroke would schedule a new timer without canceling the previous one. Typing "silkscreen" (10 letters) would leave ten timers alive, and all ten would fire: the filter would run ten times with ten different values, in order, and the debounce effect would disappear entirely. On top of that, if the component unmounted while there were pending timers, setDebounced would be called on a dead component. It is, point for point, 10-01's problem 4 and the reason for your destroy() from 09-03 — except that here React guarantees the call.

Conclusion

You have seen React in full in its modern form and, more importantly, you have seen what problem each piece solves, because you had already solved all those problems by hand.

You know that JSX is not HTML but syntactic sugar that compiles into function calls returning descriptor objects, and that all of its rules come from there: a single root element because return returns one value, expressions and not statements between braces, className because class is reserved, values that are not painted —with the 0 trap in &&— and automatic escaping which is your old textContent. You know that a component is a pure function from props to a description, with the initial capital as a compiler requirement, and that it composes by passing data, functions and children.

You have closed the circle of the key: React pairs the elements of a list by key and, without one, by position — with exactly the result you analyzed in 06-06 when reconcile did not use data-id: five nodes that change data, focus lost and internal state attributed to the wrong task. React's key is your data-id, and the three rules are the same: stable, unique among siblings and never the index if the list moves.

You know how useState works: the initial value only counts once —with the lazy form for expensive computations—, the variable does not change during the current execution because it is a closure from 03-04, updates are batched, the function form chains, and the comparison by reference with Object.is is the exact reason state is treated as immutable, with the { ...task, status: 'done' } from 04-07 and the map that preserves the references of what did not change. And you know that the shape of the state matters more than the state: do not store what you can compute, store identifiers instead of objects, and model the states so that the impossible ones cannot be expressed.

You understand useEffect precisely: it is for synchronizing with systems external to React, and not for deriving state, nor for reacting to clicks, nor for transforming data. You know the dependency array with its comparison by reference and the object-literal trap, and you know that the cleanup function is your destroy(), with identical content and the decisive difference that the call is guaranteed. You know why deriving state with effects produces two renders and two sources of truth, and why that reintroduces inside the framework exactly the problem the framework came to solve.

You distinguish useMemo, useCallback and useRef —result, function and mutable box—, you know that useMemo is your version cache from 09-02 with the dependencies acting as the counter, and you have 09-01's warning with names and procedure: memoizing is not free, useCallback without React.memo on the recipient is useless, and the right path is Profiler → expensive component → prop that changes → minimum optimization → measure again. You know how to extract custom hooksuseBoard and useAssigneeFilter— and that a hook shares logic but not state, with the two rules of hooks and their real reason: React identifies state by call order.

You know the difference between controlled and uncontrolled forms, with the criterion for choosing and the reminder that FormData, Object.fromEntries and 06-07's native validation are still the best tool for a simple creation form. You understand the virtual DOM with its three heuristic rules and its explicit trade —more work in JavaScript, less in the DOM, with a cost proportional to how much there is and not to how much changes—, and you know that the React compiler and server components exist, and which problem each one attacks. And you know how to load data with its three real problems —race condition, missing cancellation and double execution in strict mode— solved with 07-03's AbortController and a state with a phase, plus why production uses a server-state cache like TanStack Query.

Finally, you have seen the target screen written in React: TaskCard, AssigneeFilter, TaskList, two hooks and a domain file in plain JavaScript that would not change if you changed framework. With the honest comparison against board-view.js: some 130 lines versus 210, zero lines of infrastructure versus the 60 of reconcile, $, $$ and delegation — in exchange for 45 kB of engine, a mandatory build step, new concepts to debug and less direct control over the DOM. For six tasks and one person, it does not pay off. For five screens and four people, it does.

There is one problem this lesson has deliberately left out. useAssigneeFilter works because the filter lives in App and from there flows down to the two places that need it. What happens when the router, the header summary, the report and a component buried six levels deep need it too? Passing the prop through six components that do not use it has a name —prop drilling— and it is the starting point of the next lesson: State Management with Redux.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved