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
- What React is and what it is not
- Getting a project running
- JSX: what it is and what it turns into
- Function components
propsand composition- Rendering lists and the
key key: the same problem you solved in 06-06- Conditional rendering
- State with
useState - Why state is treated as immutable
- The shape of the state matters more than the state
- Events in React and how they differ from
addEventListener useEffect: what it is and what it is not- The dependency array
- The cleanup function: your
destroy() - The mistake of deriving state with effects
useMemo,useCallbackanduseRef- Do not optimize without measuring
- Custom hooks:
useBoardanduseAssigneeFilter - The rules of hooks and why they exist
- Controlled and uncontrolled forms
- The virtual DOM and reconciliation, properly
- The React compiler and server components
- Loading data with
useEffectand its three problems - Why production uses a data library
- The minimum ecosystem
- Nómada Tasks in React: the complete list
- Side-by-side comparison with
board-view.js - Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- Getting a project running
With Vite, which you have been using since 09-05, a new React project is created like this:
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.
- JSX: what it is and what it turns into
This is JSX:
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 <>…</>:
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.
- 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:
- 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. - 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
fofUI = 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.
props and composition
props and compositionProps 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.
- Rendering lists and the
key
keyA 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.
key: the same problem you solved in 06-06
key: the same problem you solved in 06-06This 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.
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.
- 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:
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:
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.
- State with
useState
useStateSo 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:
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 renderThis 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 24 · 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.
- 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 renderThe 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:
mapreturns 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 thespreadfrom 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.
- 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 handIf 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 fineYes: 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.
- Events in React and how they differ from
addEventListener
addEventListenerIn React, handlers are declared as props:
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.
useEffect: what it is and what it is not
useEffect: what it is and what it is notuseEffect 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.
useEffectis 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.
- 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 changeDependencies 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:
- Depend on primitive values:
[filters.assignee, filters.text]instead of[filters]. It is almost always the right answer. - Move the object's creation outside the component if it is constant.
- Memoize the object with
useMemoin 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.
- The cleanup function: your
destroy()
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".
- 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:
- 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.
- There are two sources of truth.
tasksandopenHourscan drift apart: it is 10-01's problem 1 reinvented inside the framework that came to solve it. - 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.
useMemo, useCallback and useRef
useMemo, useCallback and useRefThree 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 outsideNotice 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.
- 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:
- 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.
- Find the expensive component. It is almost always one: a long list, a chart, a heavy computation.
- 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
useCallbackdoes help. - 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.
- Custom hooks:
useBoard and useAssigneeFilter
useBoard and useAssigneeFilterA 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:
- They return an object, not an array. Arrays are convenient when there are two values (like
useState); with five, an object is self-documenting. - The business rules are outside.
NEXTand R1–R10 live indomain/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. - Every call to the hook creates its own state. Two components using
useAssigneeFilterhave two independent filters. A hook does not share state: it shares logic. Confusing this is misunderstanding number one about hooks. useMemohere does have a reason, and it is not the cost of the computation: it is keeping the reference stable forvisibleandassigneesso that memoized child components do not repaint for no reason.new Set(...)from 04-05 removes duplicates andfilter(Boolean)discards R8'snulls: two plain-JavaScript utilities used identically inside React.
- The rules of hooks and why they exist
Two rules, and an explanation that is almost never given:
- They are only called at the top level of a component or of another hook. Never inside an
if, a loop, atryor a nested function. - 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.
- 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.
- 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.
- 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.
- Loading data with
useEffect and its three problems
useEffect and its three problemsThis 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:
- The race disappears because the cleanup aborts the previous request before firing the new one. Iván's response never reaches
thenbecause its promise is rejected withAbortError. - Cancellation comes from the very same
AbortControllerfrom 07-03, passed tofetchJsonassignal. - 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.
- 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.
- 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.
- 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:
memomakes the card re-render only if its props change by reference. Here it does make sense, becauseuseBoardreturns the same task object for the ones that have not changed (thanks to the immutablemapfrom section 19) andonAdvanceis stable thanks touseCallback. Without those two conditions,memowould be useless.data-idis kept. React does not need it —that is whatkeyis 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]isnull, 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.
- Side-by-side comparison with
board-view.js
board-view.jsNow 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
reconcilefell 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
requestAnimationFramenow have to be done through React, withrefs 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.groupByorreduce, as in 04-05. - The component receives the tasks through props and has no state of its own.
- Add a correct
keyto 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:
- Write a
useDebounce(value, delay)hook that returns the delayed value, usinguseStateanduseEffectwith its cleanup. - Use it in a
<TaskSearch>component with a controlled<input>that filters the list by title. - 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. - 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 hooks —useBoard 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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
