Module 1: Getting Started
Thinking in React: Declarative UI and the Render Cycle
Why React asks you to describe the UI instead of changing it, what a render really is, and a repeatable way to break any design into components and state.
Before any syntax, you need one idea straight. If you get this, hooks and state will feel logical. If you skip it, they'll feel like rules someone made up.
The idea: in React, you don't change the screen. You change data, and React works out how to make the screen match.
The imperative way#
Here's a counter written the way most of us first learned, using the DOM directly.
const button = document.querySelector("#btn");
const label = document.querySelector("#count");
let count = 0;
button.addEventListener("click", () => {
count = count + 1;
label.textContent = count;
if (count >= 10) {
label.classList.add("big");
button.disabled = true;
}
});
Every time something changes, you tell the browser exactly which node to touch and how. For a counter, that's fine. Now imagine a screen with a cart, a badge in the header, a "free shipping" banner and a checkout button, all depending on the same list of items. Each handler has to remember to update every one of those places. Miss one and the UI lies.
The bug is never in the first version. It shows up in the third feature, when someone forgets a spot.
The declarative way#
React flips it. You write a function that describes what the UI should look like for a given piece of data. You never say how to get from the old screen to the new one.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
const isBig = count >= 10;
return (
<div>
<p className={isBig ? "big" : ""}>{count}</p>
<button disabled={isBig} onClick={() => setCount(count + 1)}>
Add
</button>
</div>
);
}
There's no querySelector and no step that updates the label. The component says "given this count, here's the UI." The isBig logic lives in one place, and every part of the screen that depends on it is correct automatically.
A phrase you'll see a lot: UI is a function of state. UI = f(state).
What a render actually is#
"Render" in React means React calling your component function to find out what it should look like right now. It does not mean touching the DOM. Those are separate steps.
trigger render commit
───────── ─────────────── ──────────────
first load ──► call your component ──► compare with the
setState functions, get a previous result,
parent re- description of the apply only the
rendered UI (React elements) differences to
the real DOM
- Trigger. Something causes a render: the first mount, a state update, or a parent re-rendering.
- Render. React calls your functions. The return values are plain JavaScript objects describing the UI. This is cheap and happens entirely in memory.
- Commit. React compares the new description with the previous one and changes only the DOM nodes that actually differ.
This is what people mean by the "virtual DOM". It isn't magic, and it isn't automatically faster than careful hand-written DOM code. What it buys you is that you can write the simple version (describe everything, every time) and still avoid rebuilding the whole page.
A render does not always mean the screen changed. If your component returns the same output as last time, the commit step finds no differences and the DOM is left alone. Keep that in mind when we get to performance. "It re-rendered" is not the same as "it's slow".
State is memory that survives renders#
Because React calls your function from scratch each time, ordinary local variables start over on every call. They can't remember anything.
function Broken() {
let count = 0; // reset to 0 on every render
return <button onClick={() => { count++; }}>{count}</button>;
}
Clicking this changes count in memory, but nothing tells React to render again, and even if something did, count would be 0 again. State is React's way of saying "keep this value between renders, and re-render when I change it." That's the entire job of useState, and we'll take it apart properly in module 3.
Data flows one direction#
In React, data moves down the component tree through props. A parent passes values to its children. Children never reach up and change a parent's data directly. If a child needs to cause a change, the parent hands it a function, and the child calls that function.
App (owns the cart state)
/ \
Header CartList
(reads / \
count) Item Item
│ │
calls onRemove(id) ──► back up to App
This feels restrictive for the first week and then becomes the thing that makes large apps debuggable. If a value is wrong, you only have one direction to look: up.
A repeatable process for building any UI#
When I'm handed a design, I follow roughly the same five steps. It's the process from the React docs, with my own commentary.
1. Draw boxes around the pieces. Look at the design and circle things that do one job. A search box, a product row, a filter bar. A good rule: one component, one responsibility. If you struggle to name it, it's probably two things.
2. Build a static version first. Use props only, no state. Hard-code some data, render it, make it look right. Static is a lot of typing and very little thinking, so do it separately from the interactive part.
3. Find the minimal state. Ask what actually changes over time. A search string changes. A list of products fetched from a server changes. The filtered list does not need its own state, because you can compute it from the other two. Store the smallest set of data that can describe the UI, and derive everything else.
4. Decide who owns each piece of state. Find every component that needs it, then put the state in their closest common parent. If no such component exists, make one.
5. Add the ways to change it. Pass setter functions down so children can ask the owner to update.
Here's step 3 in code, because it's where beginners go wrong most often:
// Too much state: filtered is a copy that has to be kept in sync
const [products, setProducts] = useState(allProducts);
const [query, setQuery] = useState("");
const [filtered, setFiltered] = useState(allProducts);
// Better: derive it during render
const [query, setQuery] = useState("");
const filtered = allProducts.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
The first version needs an extra update every time query changes, and one day you'll forget it. The second can't go out of sync, because there's nothing to sync.
Where people get burned#
- Treating state like a variable you can assign to. It's a snapshot, and you request changes through the setter.
- Storing derived values in state, then fighting to keep them updated.
- Reaching for
document.getElementByIdout of habit. Sometimes you need the DOM (we'll cover refs), but it should be rare. - Thinking a re-render is expensive by default. Most re-renders are fast and harmless.
- Making one giant component because splitting felt like extra work. It feels like less work for about two days.
Practice#
- Take the imperative counter above and write down every place in the DOM that depends on
count. Now rewrite it as a function component and see how many of those places you had to mention explicitly (the answer should be none). - Sketch a to-do app on paper. List every piece of data on the screen, mark which is state and which can be derived, then say which component owns each piece of state.
- A page shows a list of users with a search box and a "show only active" checkbox. How many pieces of state does it need? Write the answer, then check it against the derived-values rule.