Curriculum1 of 11
Module 09

React Fundamentals

Build a small React app and reason about state

Phase 1 · JavaScript and React · 11 sections · about 11 minutes

01 / 11

What's a component?

A component is a JavaScript function that returns one JSX root element. That root wraps everything else.

JSX is plain JavaScript pretending to be HTML. When you write <Button>Click me</Button>, React turns it into a function call: Button({ children: 'Click me' }).

JSX
1function Greeting() {
2 return <h1>Hello, world</h1>;
3}
4 
5function App() {
6 return <Greeting />;
7}

The rule is strict: one root. If you need two siblings, you wrap them. A Fragment (<>...</>) is an invisible wrapper that counts.

JSX
1function Card() {
2 return (
3 <>
4 <h2>Title</h2>
5 <p>Description</p>
6 </>
7 );
8}

React calls your function when it needs to know what should appear on screen. The function answers that question for the values it has at that moment, and React calls it again whenever those values change.

02 / 11

useState and the re-render loop

useState is how you tell React that a component has state: data that can change. When it changes, React re-runs your function to see the new output.

JSX
1import { useState } from "react";
2 
3function Counter() {
4 const [count, setCount] = useState(0);
5 
6 return (
7 <div>
8 <p>Count: {count}</p>
9 <button onClick={() => setCount(count + 1)}>Increment</button>
10 </div>
11 );
12}

Here's what happens: user clicks the button. onClick fires. setCount(count + 1) runs. React schedules a re-render. React calls Counter() again. This time useState(0) returns the new value, 1. The JSX says "Count: 1". The screen updates.

The loop is: event → setState → re-render → new output.

That description is accurate and it is also the part everyone has to see before they believe it. Two things in it sound contradictory: the whole function runs again from the top on every keystroke, and yet the value in state survives. Run it yourself.

Try itrender 1

The component function runs again. The state does not.

function UserSearch() {
const [query, setQuery] = useState("");
const matches = users.filter(
(user) => user.includes(query)
);
return (
<input value={query} onChange={...} />
{matches.map((u) => <li>{u}</li>)}
);
}

Type below to run the function.

idle
State, kept""
Renders1
Rebuilt every render
  • Ada Lovelace
  • Grace Hopper
  • Alan Turing
  • Katherine Johnson

Type a letter and watch the whole function re-run top to bottom. The one thing that survives is the value useState hands back.

Watch line 2 in particular. On the first render useState("") hands back the empty string you passed in. On every render after that it hands back whatever the state currently is, and ignores the argument entirely. That is the only line in the function with a memory. Everything else, including the filtered list, is built again from nothing each time.

Checkpoint

On the fourth render, what does useState("") return?

You can call useState multiple times in one component. Each call gets its own pair.

JSX
1function Form() {
2 const [name, setName] = useState("");
3 const [email, setEmail] = useState("");
4 
5 return (
6 <div>
7 <label htmlFor="name">Name</label>
8 <input id="name" value={name} onChange={(e) => setName(e.target.value)} />
9 
10 <label htmlFor="email">Email</label>
11 <input
12 id="email"
13 value={email}
14 onChange={(e) => setEmail(e.target.value)}
15 />
16 </div>
17 );
18}

Never set state directly. Always use the setter function. React needs to know state changed so it can re-render.

03 / 11

What React is doing for you

You have just seen React re-run a component after state changes. It is worth seeing the same job done by hand once, because then React stops being a mystery and becomes a thing that automates work you can already picture.

Tandem is a prototype I built: a split screen where a React component's code and the component itself stay in sync both ways. Drag the button on the grid and the className in the code rewrites itself. Edit the code and the button moves. There is no React in it. It is one HTML file and about a thousand lines of plain JavaScript.

The real thingOpen in a tab

Drag the button, then drag its bottom-right handle to resize it, and watch the h- and w- classes in the code change as you move. Then break a class name on purpose, like bg-neutral-90, and see what it says.

Tandemrunning

All of it runs off a few variables. The code is a string, the positions are an array, and two numbers record what is selected and what is being edited:

JavaScript
1let code = SAMPLE;
2let scene = [{ x: 6, y: 5 }];
3let selected = -1;
4let editing = -1;

Those four values are the only truth in the app. The canvas and the code panel are both drawn from them, which is why they never disagree with each other.

Drawing the canvas is a function that takes the state and makes the screen match it. Here is the middle of it, shortened:

JavaScripttandem-script.js
1function renderItems() {
2 const list = items();
NoteThe components parsed out of the code string.
3 
4 const els = [...layer.querySelectorAll(".el")];
5 els.slice(list.length).forEach((n) => n.remove());
NoteRemove any left over from last time.
6 
7 list.forEach((it, i) => {
8 let el = layer.querySelectorAll(".el")[i];
9 if (!el) {
10 el = document.createElement("button");
NoteCreate one if it does not exist yet.
11 el.className = "el";
12 bindItem(el);
13 layer.appendChild(el);
14 }
15 
16 const pos = scene[i];
17 el.style.left = (pos.x - 1) * CELL + "px";
18 el.style.top = (pos.y - 1) * CELL + "px";
19 el.style.width = it.w === null ? "auto" : it.w + "px";
20 el.style.background = it.fill;
21 el.style.borderRadius = Math.min(it.radius, 9999) + "px";
22 el.style.fontSize = it.size + "px";
23 el.style.fontWeight = it.weight;
24 });
25}

Read what that function has to do. It creates elements that do not exist yet, removes the ones that are no longer needed, and then writes every single property back onto every element, every time. Position, size, background, radius, font size, weight. Nothing is skipped, because anything you skip is a property that keeps a value from a previous state, and the screen quietly stops matching the data.

Then there is the other half of the arrangement. Nothing on screen updates on its own, so every function that changes the state has to remember to ask for a redraw:

JavaScript
1function undo() {
2 if (!past.length || editing >= 0) return;
3 future.push(snapshot());
4 restore(past.pop());
Noterestore() ends by calling render().
5}

render() is called in thirteen different places in that file. Thirteen chances to forget.

This is the job React takes off you. useState is two things at once: a value that survives re-runs, and a message to React that when this value changes, the component needs drawing again. You never call a render function, and you never write el.style.background by hand. You describe what the button should look like for the current state, and React compares that description with what is on screen and changes only the parts that differ.

Checkpoint

In Tandem, what goes wrong if a new property is added to an item but renderItems forgets to write it back onto the element?

The rest of this module is React doing this for you. It is worth coming back to this page at the end, once the React version is familiar, and reading it again.

04 / 11

Derived vs. stored state

Derived state is computed from other state. Stored state lives directly in useState.

If you have both weight and height, don't store BMI in state. Calculate it every render.

JSX
1function BMI() {
2 const [weight, setWeight] = useState(70);
3 const [height, setHeight] = useState(1.75);
4 
5 // Derived: calculate it fresh every render
6 const bmi = weight / (height * height);
7 
8 return (
9 <div>
10 <label htmlFor="weight">Weight in kg</label>
11 <input
12 id="weight"
13 value={weight}
14 onChange={(e) => setWeight(Number(e.target.value))}
15 />
16 
17 <label htmlFor="height">Height in metres</label>
18 <input
19 id="height"
20 value={height}
21 onChange={(e) => setHeight(Number(e.target.value))}
22 />
23 <p>BMI: {bmi.toFixed(1)}</p>
24 </div>
25 );
26}

Why? Because now weight and height are always in sync with BMI. If you stored BMI separately, you'd have to update it manually whenever weight or height changed. Easy to forget. State goes stale.

The rule: if it can be computed from other state, don't store it.

Checkpoint

A search page holds the query in state. Should the filtered results also live in useState?

05 / 11

The controlled input

An input is controlled when React holds its value in state. You set the value prop and handle onChange to update state.

JSX
1function SearchBox() {
2 const [query, setQuery] = useState("");
3 
4 return (
5 <>
6 <label htmlFor="search">Search</label>
7 <input
8 id="search"
9 type="text"
10 value={query}
11 onChange={(e) => setQuery(e.target.value)}
12 placeholder="Search..."
13 />
14 </>
15 );
16}

React is now the source of truth for the input's value. The input can't drift out of sync with your state.

An uncontrolled input leaves the value to the browser. You read it when you need it with ref. Avoid refs for inputs unless you have a specific reason, like focus management or file uploads.

Controlled inputs are predictable. They're the default in React.

06 / 11

Props: data parent to child

Props are how a parent component passes data to a child. They're function arguments.

JSX
1function Header({ title, subtitle }) {
2 return (
3 <header>
4 <h1>{title}</h1>
5 <p>{subtitle}</p>
6 </header>
7 );
8}
9 
10function App() {
11 return <Header title="Welcome" subtitle="React basics" />;
12}

Props are read-only from the child's perspective. You can't call setTitle(...) inside Header and expect the parent to update. That violates the one-way flow: parent owns the data, parent can change it, child reads it.

If a child needs to change data, it calls a function the parent passed down.

JSX
1function Button({ label, onClick }) {
2 return <button onClick={onClick}>{label}</button>;
3}
4 
5function App() {
6 const [count, setCount] = useState(0);
7 return <Button label="Click me" onClick={() => setCount(count + 1)} />;
8}

Here's a small naming convention baked into the language: onClick is reserved for the click handler prop. If you try onClick on a div, it won't work. That's "chosen, not fixed." The platform chose camelCase and reserved these names. Your code has to match.

07 / 11

Composition into a tree

Real apps are hierarchies of components. Each piece handles its own slice of the UI.

JSX
1function SearchResults({ results }) {
2 if (results.length === 0) return <p>No results</p>;
3 
4 return (
5 <ul>
6 {results.map((item) => (
7 <ResultItem key={item.id} item={item} />
8 ))}
9 </ul>
10 );
11}
12 
13function ResultItem({ item }) {
14 return <li>{item.name}</li>;
15}
16 
17function SearchPage() {
18 const [query, setQuery] = useState("");
19 const results = fakeSearch(query);
20 
21 return (
22 <div>
23 <label htmlFor="search">Search</label>
24 <input
25 id="search"
26 value={query}
27 onChange={(e) => setQuery(e.target.value)}
28 />
29 <SearchResults results={results} />
30 </div>
31 );
32}

Each component has one job. SearchPage manages the query. SearchResults renders a list. ResultItem renders one item. The tree is clear.

When the query changes, SearchPage re-renders, passes new results to SearchResults, which re-renders its children. This flows naturally.

Always add a key prop when rendering lists. It helps React track which item is which if the list reorders.

08 / 11

Lifting state up

If two sibling components need to share state, move that state to their common parent.

JSX
1function Tab({ label, isActive, onClick }) {
2 return (
3 <button
4 onClick={onClick}
5 style={{ fontWeight: isActive ? "bold" : "normal" }}
6 >
7 {label}
8 </button>
9 );
10}
11 
12function Tabs() {
13 const [activeTab, setActiveTab] = useState("home");
14 
15 return (
16 <div>
17 <div>
18 <Tab
19 label="Home"
20 isActive={activeTab === "home"}
21 onClick={() => setActiveTab("home")}
22 />
23 <Tab
24 label="About"
25 isActive={activeTab === "about"}
26 onClick={() => setActiveTab("about")}
27 />
28 </div>
29 <div>
30 {activeTab === "home" && <p>Home content</p>}
31 {activeTab === "about" && <p>About content</p>}
32 </div>
33 </div>
34 );
35}

Both tabs need to know which one is active. Instead of storing it in each tab separately, store it in Tabs and pass it down as props. Now they always agree.

This pattern scales to any number of siblings. The parent is the single source of truth.

Checkpoint

Two sibling components need to show the same selected item. Where does that selection belong?

09 / 11

The four states

Every async operation has four states: idle (waiting to start), loading (in flight), empty (done, no data), error (done, failure).

Real UIs handle all four.

JSX
1function BookSearch() {
2 const [query, setQuery] = useState("");
3 const [state, setState] = useState("idle");
4 const [results, setResults] = useState([]);
5 const [error, setError] = useState(null);
6 
7 const handleSearch = async (q) => {
8 if (!q) {
9 setState("idle");
10 setResults([]);
11 return;
12 }
13 
14 setState("loading");
15 try {
16 const data = await fetch(`/api/books?q=${q}`).then((r) => r.json());
17 if (data.length === 0) {
18 setState("empty");
19 setResults([]);
20 } else {
21 setState("idle");
22 setResults(data);
23 }
24 } catch (err) {
25 setState("error");
26 setError(err.message);
27 }
28 };
29 
30 return (
31 <div>
32 <label htmlFor="book-search">Search books</label>
33 <input
34 id="book-search"
35 value={query}
36 onChange={(e) => {
37 setQuery(e.target.value);
38 handleSearch(e.target.value);
39 }}
40 placeholder="Try a title or author"
41 />
42 
43 {state === "idle" && results.length > 0 && (
44 <ul>
45 {results.map((book) => (
46 <li key={book.id}>{book.title}</li>
47 ))}
48 </ul>
49 )}
50 
51 {state === "loading" && <p>Searching...</p>}
52 {state === "empty" && <p>No books found</p>}
53 {state === "error" && <p>Error: {error}</p>}
54 </div>
55 );
56}

This is tight. Every state has its own UI. No results render until data arrives. Loading UI shows immediately. Errors have a home.

The four states are a checklist: idle, loading, empty, error. Write them down and check your UI handles all four. Most bugs are in the states you skipped.

Checkpoint

A search returns zero results and your UI renders nothing at all. Which state did you skip?

10 / 11

Build: Search interaction in React

Let's build a real search experience split into components. A search box, a results list, and proper state handling.

JSX
1function SearchBox({ query, onQueryChange }) {
2 return (
3 <>
4 <label htmlFor="book-search">Search books</label>
5 <input
6 id="book-search"
7 type="text"
8 value={query}
9 onChange={(e) => onQueryChange(e.target.value)}
10 placeholder="Try a title or author"
11 />
12 </>
13 );
14}
15 
16function SearchResults({ state, results, error }) {
17 if (state === "loading") return <p>Searching...</p>;
18 if (state === "empty") return <p>No books found</p>;
19 if (state === "error") return <p>Error: {error}</p>;
20 if (results.length === 0) return null;
21 
22 return (
23 <ul>
24 {results.map((book) => (
25 <li key={book.id}>
26 {book.title} by {book.author}
27 </li>
28 ))}
29 </ul>
30 );
31}
32 
33function BookSearch() {
34 const [query, setQuery] = useState("");
35 const [state, setState] = useState("idle");
36 const [results, setResults] = useState([]);
37 const [error, setError] = useState(null);
38 
39 const handleSearch = async (q) => {
40 setQuery(q);
41 
42 if (!q.trim()) {
43 setState("idle");
44 setResults([]);
45 return;
46 }
47 
48 setState("loading");
49 try {
50 const response = await fetch(`/api/books?q=${encodeURIComponent(q)}`);
51 const data = await response.json();
52 
53 if (data.length === 0) {
54 setState("empty");
55 } else {
56 setState("idle");
57 setResults(data);
58 }
59 } catch (err) {
60 setState("error");
61 setError(err.message);
62 }
63 };
64 
65 return (
66 <div>
67 <SearchBox query={query} onQueryChange={handleSearch} />
68 <SearchResults state={state} results={results} error={error} />
69 </div>
70 );
71}

The structure: BookSearch owns all state. It passes the query to SearchBox and the results to SearchResults. When you type, SearchBox calls onQueryChange, which runs the async fetch and updates state. SearchResults re-renders with the new state.

Each component is small and testable. The flow is clear: parent to child via props, child to parent via callbacks.

11 / 11

Checkpoint

Before you move on, make sure you can answer these aloud.

No derived data in useState. Look at your state shape. If something can be calculated from other state, remove it. Keep only the irreducible facts.

Justify the state shape aloud. Say why each piece of state is there. If you can't explain it in one sentence, it might be derived or unnecessary.

Handle all four states. Write code for idle, loading, empty, and error. Real apps need all four. If your UI doesn't show loading, users will think it froze.

Hand-write a prop. Create a component with three props. Pass them from a parent. Don't skip steps. Typing it yourself is the point.

Also: notice how onClick is spelled camelCase. That's not a bug. That's the platform. React chose it. Your code has to match. There's no "correct" spelling in the abstract. There's only what the platform chose, and your code following it. This is "chosen, not fixed," and it's how you learn to work with systems instead of against them.

Up nextTesting React UINext modulePreviouslyTypeScript for UI