Lesson 2 · Basics
State and events (useState)
Immutability, updater functions, batching, lifting state up and derived state.
Loading lesson…
Lesson 2 · Basics
Immutability, updater functions, batching, lifting state up and derived state.
Loading lesson…
Look at any app you use every day. The number on the cart icon, an open menu, half-typed text in the search box, a ticked task, the selected tab. These are all things that change depending on what the user does. And for them to change, the app has to remember them somewhere: "there are three items in the cart now", "the menu is open". This memory is what React calls state.
From the last lesson you know that a component is a function: it gets data and returns a description of what should be displayed. Picture it as a cook working from a recipe. They get ingredients (props), cook a dish (JSX) and hand it over. But they have one peculiarity: after every dish they forget everything. When you ask for another portion, they start completely from scratch and have no idea what they cooked before.
That's normal for functions. Everything a function creates inside (variables) disappears as soon as it finishes. So a component has no way of remembering on its own that the button has already been clicked three times.
State is a notebook React keeps for the cook. Every time they cook, React opens it at the right page: "here's what you wrote down last time". And when the cook wants to change the note, they don't cross things out in the notebook themselves. They tell React what should be there next time, and React then has them cook a new dish according to the new note.
In plain JavaScript, after a click you'd find the element on the page yourself and rewrite its text. React works the other way round. You just change the state, React calls the component again and it works out from the new data what the screen should look like now. React then finds and rewrites what has really changed on the screen.
| Plain JavaScript | React |
|---|---|
| "Find the label and rewrite it to 4." | "The count is 4 now." |
| You make sure the screen matches the data. | The screen is always calculated afresh from the data. |
| It’s easy to forget to update some place. | Every place that shows the count sorts itself out. |
Every call of a component (a render) creates one frame of the screen according to what's in the state at that moment. A film is made by frames following one another. Between two frames the state changes and React renders the next one. But inside one frame nothing changes – the values in it are "frozen". This analogy will come in handy mainly in the section on why the new value of state doesn't show up right away.
We'll start with the simplest thing a component can remember: a single number. We want a button that raises a number by one on every click. Someone who knows JavaScript but not React would probably write this:
function Counter() {let count = 0function increment() {count = count + 1}return <button onClick={increment}>Count: {count}</button>}
The screen will keep showing Count: 0. Yet the variable really does grow. There are two problems here, and both come from the fact that a component is an ordinary function:
Counter function again, so the JSX isn't calculated again and the screen stays the same.Counter function again from the start. The line let count = 0 runs again and the value is back at zero.This is exactly the cook without a memory from the introduction. We need a value that someone remembers between calls of the function and whose change causes a new call. We need the notebook – state.
What you see: Two counters side by side. The left one is written with an ordinary variable let count = 0, the right one with useState. Under each button is a line that shows what value is really in memory. The card React has just rendered briefly flashes yellow.
Try it:
let count = 0 ran again.The takeaway: An ordinary variable has both problems from the text above the demo: its change doesn't trigger a render, and the next render resets it. State has neither.
Loading the interactive part…
import { useState } from 'react'function Counter() {const [count, setCount] = useState(0)function increment() {setCount(count + 1)}return <button onClick={increment}>Count: {count}</button>}
Let's go through it line by line:
useState(0) tells React: "this component needs one page in the notebook; at the start it will hold 0". The initial value is used only on the first render. On later renders React returns whatever it has written down.count is the current value for this render, setCount is the function for changing it. You choose the names; the convention is [something, setSomething].setCount(count + 1) doesn't rewrite anything directly. It tells React: "next time I want a value one bigger, and re-render me".count is a const because you never change it within one render. It gets a new value only on the next call of the function.Counter(). useState(0) has nothing written down yet, so it writes 0 and returns count = 0. The function returns JSX with the text "Count: 0" and React writes it into the DOM.increment, which calls setCount(0 + 1). React notes the new value 1 and schedules a new render. At this moment nothing has re-rendered yet and count in this handler is still 0.Counter() again. This time useState(0) ignores the zero and returns the 1 it has written down. The function returns JSX "Count: 1".Form fields are special: a text field remembers what you type into it even without React. The browser has that memory. If we also wrote the same thing into state, we'd have two notebooks with the same information and would have to make sure they don't drift apart. React solves it simply: it tells the browser "don't remember anything, the field will always contain what's in the state". The state is the boss, the input just shows its contents. Such a field is called controlled.
A typical example: an input for a name, with a greeting and a character counter below it.
function Greeting() {const [name, setName] = useState('')const trimmed = name.trim()const greeting = trimmed === '' ? 'Hello, stranger!' : `Hello, ${trimmed}!`return (<><input value={name} onChange={(e) => setName(e.target.value)} /><p>{greeting}</p><p>{trimmed.length} / 20 characters</p></>)}
What happens when you type "A" into the empty field:
change event and React calls onChange. In e.target.value is the text that would be in the field: "A".setName("A") writes the new value into the notebook and schedules a render.name === "A". The greeting "Hello, A!" and the length 1 are calculated.value="A", so the letter appears in the field. The greeting and the counter change in the same render.Take three things away from the example:
onChange, you couldn't type into the field: the state would stay empty and React would keep putting empty text back into the field.name, so they're calculated on every render. The greeting can never show a different name than the input.What you see: The component from the example above, with a Clear button added. There's a single value in state – name. The greeting, the character count, the red warning and whether the button is enabled are all just calculated from it.
Try it:
name.trim().setName(''). The input empties, the greeting goes back to "stranger" and the button disables itself.The takeaway: It's enough to change one value in state, and every place that depends on it sorts itself out. The input shows the state, not the other way round.
Loading the interactive part…
Back to the cook. Imagine they're cooking for a wedding and write the guest list in the notebook. Should they also write "number of guests: 48" next to it? They don't need to – whenever they need it, they count the lines in the list. And if they wrote it down, they'd have to correct two places after every new guest. One day they'd forget and the numbers would start contradicting each other.
It's the same with state. Only what changes over time and can't be calculated from anything else belongs in the notebook. Everything else the component works out on every render. Such values are called derived.
A typical mistake looks like this – a todo list, a filter and the number of finished ones:
// ❌ Three entries in the notebook, even though two are enoughconst [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')const [visibleTodos, setVisibleTodos] = useState<Todo[]>([])// …and code that keeps chasing the third entryuseEffect(() => {setVisibleTodos(filter === 'all' ? todos : todos.filter((t) => t.done))}, [todos, filter])
It works, but badly: after every change the component renders twice (once with the old list, a second time with the recalculated one), and there's one more place where things can drift apart. The right way:
// ✅ Only what can't be calculated goes in stateconst [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')// Everything else is calculated during render – always up to dateconst visibleTodos = filter === 'all' ? todos : todos.filter((t) => t.done)const doneCount = todos.filter((t) => t.done).length
When you're not sure, ask these questions about the value:
| Question | If yes… |
|---|---|
| Does it come from the parent through props? | It isn’t state (it’s a prop). |
| Does it stay the same the whole time? | It isn’t state (it’s a constant). |
| Can it be calculated from other state or props? | It isn’t state – calculate it during render. |
| Does it change, and does the UI have to react to it? | It’s state. |
Remember the film. Every render is one frame, and in it everything is frozen – including the values of state. When you call setCount in a handler, you're not changing the frame that's playing right now. You're placing an order for the next frame. That's why after setCount(count + 1) you still see the old value in count – you're still in the old frame.
And one more thing: React doesn't run "to the kitchen" with every change separately. It's like a waiter who writes down the whole order from the table and goes to the kitchen once. It notes all the setState calls from one handler and renders one new frame when the handler finishes. This is called batching.
function addThree() {setCount(count + 1)setCount(count + 1)setCount(count + 1)}
We'd expect +3, but the result is +1. All three lines read the same frame. When count is 0 in it, React gets three identical orders:
| The call | What gets substituted | What React notes |
|---|---|---|
setCount(count + 1) | setCount(0 + 1) | "set it to 1" |
setCount(count + 1) | setCount(0 + 1) | "set it to 1" |
setCount(count + 1) | setCount(0 + 1) | "set it to 1" |
| The next frame | count = 1 |
The fix is to write an instruction on the ticket instead of "set it to 1": "add one to whatever will be there". In React this is an updater function: instead of a value you pass a function that gets the previous value and returns the new one. React handles the instructions one by one and passes each the result of the previous one:
function addThree() {setCount((c) => c + 1) // 0 → 1setCount((c) => c + 1) // 1 → 2setCount((c) => c + 1) // 2 → 3}
What you see: Two values in state, a and b. The red button calls setA(a + 1) three times, the blue one setB(prev => prev + 1) three times. The +10 and alert(b) button calls setB(b + 10) and right after that prints b.
Try it:
a goes up only by 1 – all three calls read the same frame, where a was 0.b goes up by 3 – each updater got the result of the previous one.b, even though setB has already run. After you close the dialog, a value 10 higher appears on the page.The takeaway: setState doesn't change the variable in the running code, it just orders the next frame. When you build on the previous value, pass an updater function.
Loading the interactive part…
The frozen frame shows even more in code that runs later – in a setTimeout, after data loads, in an interval. The function carries the values from the frame in which it was created, even if more frames have played in the meantime.
function addLater() {// count is "photographed" at the moment of the clicksetTimeout(() => setCount(count + 1), 2000)}
You click five times quickly. All five clicks happened in a frame where count was 0, so two seconds later "set it to 1" arrives five times. The result: 1. With the updater setCount(c => c + 1) "add one" arrives five times and the result is 5. Try it:
What you see: Two buttons that add one only after two seconds. The red one uses the value from the frame (setWrong(wrong + 1)), the blue one an updater (setRight(r => r + 1)).
Try it:
The takeaway: A function that runs later carries the values from the frame in which it was created. The updater picks up the current value only at the moment it really runs.
Loading the interactive part…
So far we've had a number and text in the notebook. As soon as you write an object or an array there, one more rule appears. To understand it, you need to know how React recognises that something has changed.
React doesn't read the whole contents of the notebook and compare it line by line – with big data that would be slow. It only looks at whether it got a new sheet of paper. If you rub something out on the old sheet, rewrite it and hand it over again, React looks, says "I already have this sheet" and re-renders nothing. In technical terms: it compares references (Object.is), not contents.
const [todos, setTodos] = useState(['Go shopping', 'Tidy up'])function addTodo() {// ❌ Rewriting on the old sheettodos.push('Walk the dog')setTodos(todos)}
push changed the contents of the existing array, but it's still the same array. React compares the old and new value, finds it's the same object and skips the render. The todo is in the array, but not on the screen.
The right way is to take a new sheet, copy the old items onto it and add the new one:
function addTodo() {// ✅ A new array → a new reference → React knows it should re-rendersetTodos((prev) => [...prev, 'Walk the dog'])}
The notation [...prev, x] means "a new array: all the elements from prev and x after them". The original array stays untouched.
The same goes for objects. You don't change user.name = 'Eve', you create a new object { ...user, name: 'Eve' } – "everything from user, only name will be different". When the changed value is nested deeper, you have to make a new copy of every level on the way to it:
const [person, setPerson] = useState({name: 'Anna',address: { city: 'London', street: 'Baker Street 1' },})// Moving to Boston:setPerson((p) => ({...p, // a new person object (the name is copied)address: {...p.address, // a new address object (the street is copied)city: 'Boston', // and only this value is different},}))
In the demo, notice a treacherous thing: a mutation doesn't "get lost". When you click Mutate, nothing happens. But as soon as something else triggers a render, the mutated data suddenly appear. In a real app such a bug is very hard to find, because it shows up somewhere else and at a different time than it was created.
What you see: A person object in state, printed as JSON. The red Mutate button does person.tags.push(…) and passes React the same object. The other buttons create new copies: they add a tag, remove the first tag, or change the city in the nested address object.
Try it:
tags array.The takeaway: A mutation doesn't get lost, it just doesn't show until something else triggers a render. That's why you should always pass React a new object or array.
Loading the interactive part…
A cheat sheet for the most common operations. All of them return a new value and leave the original alone:
setItems((prev) => [...prev, newItem]) // addsetItems((prev) => prev.filter((i) => i.id !== id)) // removesetItems((prev) => prev.map((i) => (i.id === id ? { ...i, done: true } : i))) // update onesetItems((prev) => prev.toSorted((a, b) => a.price - b.price)) // sort (not .sort()!)setUser((prev) => ({ ...prev, address: { ...prev.address, city } })) // a nested object
A restaurant doesn't have just one cook. Picture two: one takes orders, the other prepares the bill. If each kept their own notebook, the bill would never match what was ordered. The fix is simple: the notebook goes to the head chef, who is above both of them. The cook taking orders just reports "table 4 wants soup", and the cook doing the bill looks in the head chef's notebook.
In React the head chef is the parent component. Data can't be sent directly between siblings – it flows only top down through props. So when two components need the same data, you move the state into their nearest common parent. This is called lifting state up.
// ❌ Each component has its own notebook – they don't know about each otherfunction Catalog() {const [cart, setCart] = useState<string[]>([])return <button onClick={() => setCart([...cart, 'shoes'])}>Add to cart</button>}function Cart() {const [cart, setCart] = useState<string[]>([]) // always empty!return <p>In the cart: {cart.length}</p>}
Two useState calls are two independent notebooks. The catalogue writes into its own, the cart looks into its own, which stays empty. The fixed version:
// ✅ The parent has the notebook – the single source of truthfunction Shop() {const [cart, setCart] = useState<string[]>([])const add = (item: string) => setCart((prev) => [...prev, item])return (<><Catalog onAdd={add} /> {/* gets only the function for changing it */}<Cart items={cart} /> {/* gets only the value */}</>)}function Catalog({ onAdd }: { onAdd: (item: string) => void }) {return <button onClick={() => onAdd('shoes')}>Add to cart</button>}function Cart({ items }: { items: string[] }) {return <p>In the cart: {items.length}</p>}
What happens after clicking "Add to cart":
Catalog calls onAdd('shoes'). That's a function the parent sent it – the catalogue just "reports to the head chef" what happened.add function in the parent calls setCart. The parent's state changes.Cart gets new items and shows "In the cart: 1".The demo shows the same principle in a currency converter. Two fields, but the parent's state holds only one amount and the currency it was entered in. The parent calculates the other value – section 3 in practice.
What you see: Two fields, in euros and in dollars (rate 1 EUR = 1.10 USD). Both are the same CurrencyInput component, which has no state of its own. The parent remembers only two things: the entered amount and the currency the user entered it in. It always calculates the other field.
Try it:
The takeaway: The two fields never drift apart, because they read from one place. If each CurrencyInput had its own state, you'd have to keep them in sync by hand.
Loading the interactive part…
A good notebook has one property: it can't contradict itself. Every piece of information is in it once and in only one form. The following four rules are really just different forms of this idea.
A form is being sent to the server. It's often written like this:
// ❌ Two switches = four combinations, but only three make senseconst [isSending, setIsSending] = useState(false)const [isSent, setIsSent] = useState(false)// isSending && isSent — "sending and sent at the same time"? Nonsense, but it can be written.
Forget to reset one of the switches once, and the UI shows a spinner and a confirmation at the same time. The fix: one value that can only take valid states.
// ✅ An impossible state can't be writtenconst [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle')// and feel free to calculate derived valuesconst isSending = status === 'sending'
In a list you want to show the detail of the selected item. It's tempting to store the whole selected object in state. But then the notebook has the same item twice: in the list and in the selection. When you rename the item in the list, the copy in the selection doesn't know about it.
// ❌ A copy: after renaming, the detail shows the old nameconst [selected, setSelected] = useState<Fruit | null>(null)// ✅ Just the ID, the object is looked up during render – always up to dateconst [selectedId, setSelectedId] = useState<number | null>(null)const selected = fruits.find((f) => f.id === selectedId) ?? null
What you see: A list of fruit and, below it, two details of the selected item. The red one stores a copy of the object when you select, the green one only its ID, and looks the object up in the list every time. The Rename button adds a star to the name.
Try it:
The takeaway: A copy is a second record of the same information, and sooner or later it drifts apart from the original. An ID points to the single source of truth.
Loading the interactive part…
If you always set two values at the same time (the cursor's x and y coordinates), it's clearer to keep them in one piece of state { x, y }. On the other hand, values that change independently (the name and e-mail in a form) can each have their own useState.
// ❌ The colour is taken from props only on the first render – later changes are ignoredfunction Badge({ color }: { color: string }) {const [currentColor, setCurrentColor] = useState(color)// …}// ✅ Just use the prop directlyfunction Badge({ color }: { color: string }) {return <span style={{ color }}>…</span>}
Remember: the initial value of useState(x) is used only on the first render. If you really want the prop only as a default value that the component keeps adjusting itself, name it so it's obvious: initialColor.
The state is ready – you just need to change it from the buttons.
CHALLENGE: Counter The `count` state is ready. The buttons don't do anything yet. 1. Make the "+1" button call setCount(count + 1) on click. 2. Make the "Reset" button call setCount(0).
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
Now try it yourself. The first challenge practises sections 3 and 5 (derived values and working with an array without mutations), the second section 6 (shared state in the parent). Before you start writing, think about each challenge: what will be in state, where that state will live and what I'll just calculate.
A classic on which you'll practise all three immutable operations: adding, updating and deleting. Follow the points in the file's comment and after each point check that the screen behaves correctly.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
The catalogue and the cart are siblings – exactly the "two cooks, one notebook" situation. The state has to live in the parent. Notice also the advice to store only the productId and quantity in the cart, not copies of the products (section 7).
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
useState keeps the value and its setter triggers a new render.setState → a new render → React reflects the differences in the DOM.onChange.setX(prev => …).map, filter, toSorted).