State is a component's memory between renders: useState returns the current value and a function that stores a new one and asks React to re-render the component. Within a single render the value is a fixed snapshot, so updates based on the previous value are written as a function: setCount(c => c + 1). Objects and arrays in state are never mutated but replaced with new ones, and state shared by several components is lifted up to their closest common parent.
After this you will be able to
- Explain why a regular variable doesn't trigger a re-render and why state is a snapshot
- Update state with a
setX(prev => …)function when the new value depends on the old one - Change objects and arrays in state without mutating them: spread,
map,filter - Build a controlled input and lift shared state up to a parent, deriving values during render
Know beforehand
Verified with
React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3@testing-library/user-event 14.6.7Node.js 24 LTS (24.18.0)
Checked against docs: October 11, 2026
Contents
State as a component's memory
In the previous lesson the catalogue rendered whatever came in through props and could do nothing else. Now the UI has to respond to actions: “Add to cart” adds an item, “+” and “−” change the quantity, and a promo code field applies a discount. For that, a component needs memory: state.
A first attempt is a regular variable. Its value changes, but the screen stays the same.
import { useState } from 'react'
export function BrokenCounter() {
let count = 0
function handleClick() {
count = count + 1 // the value changes, but React doesn't know about it
}
return <button onClick={handleClick}>Broken counter: {count}</button>
}
export function Counter() {
const [count, setCount] = useState(0)
function addThreeWrong() {
// in this render count is a snapshot, say 0: “replace with 1” three times
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
function addThree() {
// updater functions are queued: 0 → 1 → 2 → 3
setCount((c) => c + 1)
setCount((c) => c + 1)
setCount((c) => c + 1)
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={addThreeWrong}>+3 with snapshot</button>
<button onClick={addThree}>+3 with updater</button>
</div>
)
}The React docs give two reasons why BrokenCounter doesn't work. First, local variables don't persist between renders: the next time the function runs, count is 0 again. Second, changing a local variable doesn't trigger a render: React simply doesn't know the data has changed.
The useState Hook solves both: it provides a state variable that persists between renders and a setter function that updates the value and asks React to render the component again. const [count, setCount] = useState(0) destructures a two-item array; 0 is the initial value. TypeScript infers the type from it: number here.
- Hooks are functions whose names start with
use. By React's rules, you call them only at the top level of a component or of your own Hook, never inside conditions, loops or nested functions. - State belongs to a component instance. Render
<Counter />twice and each copy gets its own independent value. - State is private: unlike props, the parent can't change it directly.
State as a snapshot
The most surprising thing about useState for newcomers: calling setCount doesn't change the count variable you already have. The docs put it this way: state behaves more like a snapshot. The setter asks React for a new render, and count has its new value in that render. Within the current render, including all of its event handlers, the value doesn't change.
You can see this with the “+3 with snapshot” button. The handler calls setCount(count + 1) three times, but in all three calls count is this render's value, say 0. That's “replace with 1” three times, so the counter goes up by one, not three. The test at the end of the lesson checks this.
setCount(count + 1) changes count immediately
- What actually happens
countstays the same in this render; the new value arrives with the next render
console.log(count) after the setter shows the new value
- What actually happens
- it shows the old one. If you need the new value, store it in a variable:
const next = count + 1; setCount(next)
Three setCount(count + 1) calls add 3
- What actually happens
- they add 1: all three read the same snapshot
A setTimeout in the handler sees the latest value
- What actually happens
- it sees the value from the render in which the handler was created
Updating based on the previous value
If the new value depends on the previous one, pass the setter an updater function instead of a value: setCount((c) => c + 1). React queues these functions and, once the handler finishes, runs them in order, passing each one the result of the previous one. That's why the “+3 with updater” button really adds three.
The docs explain the queue like this: React waits until all the code in the event handler has run before processing state updates. This is called batching. Values and functions can be mixed in the queue:
setCount(count + 5)
- What gets queued
- “replace with 5”
- Result with
count = 0 - 5
setCount((c) => c + 1)
- What gets queued
- the function
c => c + 1 - Result with
count = 0 - 5 + 1 = 6
setCount(42) after them
- What gets queued
- “replace with 42”, discarding everything before it
- Result with
count = 0 - 42
- By convention, the updater's argument is named after the first letter of the state variable (
cforcount) or the full name with aprevprefix:setCart((prev) => …). - An updater must be pure: it should only compute the new value from the old one. In development under StrictMode, React calls it twice to surface accidental side effects.
- Don't confuse passing a function with calling it:
useState(createInitialCart)runs the initializer once, whileuseState(createInitialCart())runs it on every render and throws the result away.
Objects and arrays: updates without mutation
State can hold an object or an array. The rule from the React docs: treat anything you put into state as read-only. You can't change an object or array in place; you create a new one and pass it to the setter. Here's what the mistake looks like:
function addToCart(id: string) {
const line = cart.find((l) => l.id === id)
if (line) {
line.qty += 1 // mutates an object that lives in state
} else {
cart.push({ id, qty: 1 }) // mutates the state array itself
}
setCart(cart) // same array: React compares with Object.is and skips the render
}The data does change, but setCart(cart) receives the very same array. React compares the old and new values with Object.is, sees they're identical and skips the render. We checked this with a test: after the click, the screen still shows “Your cart is empty” and a total of 0 ₸. Worse, the mutation also changes the “old” snapshots that other code may rely on.
function addToCart(id: string) {
setCart((prev) =>
prev.some((line) => line.id === id)
? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
: [...prev, { id, qty: 1 }],
)
}Add
- Avoid (mutates)
push,unshift- Use (returns something new)
[...arr, item],concat
Remove
- Avoid (mutates)
pop,shift,splice- Use (returns something new)
filter,slice
Replace or change an item
- Avoid (mutates)
arr[i] = …,splice- Use (returns something new)
map
Sort or reverse
- Avoid (mutates)
sort,reverseon the original array- Use (returns something new)
- copy first:
[...arr].sort()
Change an object field
- Avoid (mutates)
obj.qty = 2- Use (returns something new)
{ ...obj, qty: 2 }
The ... spread copies only one level deep. To update a nested field, copy every level from the changed field upwards: { ...order, delivery: { ...order.delivery, fee: 0 } }. For deep nesting, the docs suggest two options: flatten the state structure, or use the Immer library, which lets you write “mutating” code and creates the copies for you.
Controlled inputs
The promo code field is a controlled input: its value is driven by state, not by the browser. The pairing is always the same: value from state and an onChange that synchronously stores the new value.
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />This lets you not only store input but normalise it on the spot: trim spaces, upper-case it, drop unwanted characters. For checkboxes, use checked instead of value and read e.target.checked.
valuewithoutonChangemakes the field impossible to type into: React restores the state value after every keystroke. In development it warns about this in the console. If you only need an initial value, usedefaultValue.valuemust not beundefinedornull, or React treats the field as uncontrolled and warns when a string shows up later. The initial value for an empty field is''; for API data usevalue={name ?? ''}.- An input's value is always a string, even with
type="number". Keep the string and convert it when calculating:Number(qty).
Lifting state up to the common parent
The menu and the cart are separate components but work with the same data: a button in the menu changes the cart's contents. If each kept its own copy, they'd soon fall out of sync. The React docs describe the solution, lifting state up: remove the state from the child components, move it to their closest common parent, and pass it down through props together with event handlers.
export interface MenuItem {
id: string
name: string
price: number // tenge
}
export interface CartLine {
id: string // a reference to a MenuItem, not a copy of it
qty: number
}
export const menu: MenuItem[] = [
{ id: 'cappuccino', name: 'Cappuccino', price: 1600 },
{ id: 'latte', name: 'Latte', price: 1800 },
{ id: 'cheesecake', name: 'Cheesecake', price: 2400 },
]
export const PROMO_CODE = 'AUTUMN'
export const PROMO_PERCENT = 10import type { MenuItem } from '../data/menu.ts'
interface MenuProps {
items: MenuItem[]
onAdd: (id: string) => void
}
export function Menu({ items, onAdd }: MenuProps) {
return (
<ul className="menu">
{items.map((item) => (
<li key={item.id}>
{item.name} — {item.price} ₸{' '}
<button onClick={() => onAdd(item.id)}>Add to cart</button>
</li>
))}
</ul>
)
}import type { CartLine, MenuItem } from '../data/menu.ts'
interface CartProps {
lines: CartLine[]
items: MenuItem[]
onChangeQty: (id: string, delta: number) => void
onRemove: (id: string) => void
}
export function Cart({ lines, items, onChangeQty, onRemove }: CartProps) {
if (lines.length === 0) {
return <p>Your cart is empty</p>
}
return (
<ul className="cart">
{lines.map((line) => {
const item = items.find((i) => i.id === line.id)
if (!item) return null
return (
<li key={line.id}>
{item.name}
<button aria-label={`Fewer: ${item.name}`} onClick={() => onChangeQty(line.id, -1)}>
−
</button>
<span aria-label={`Quantity: ${item.name}`}>{line.qty}</span>
<button aria-label={`More: ${item.name}`} onClick={() => onChangeQty(line.id, 1)}>
+
</button>
{item.price * line.qty} ₸
<button onClick={() => onRemove(line.id)}>Remove</button>
</li>
)
})}
</ul>
)
}import { useState } from 'react'
import { Cart } from './components/Cart.tsx'
import { Menu } from './components/Menu.tsx'
import { menu, PROMO_CODE, PROMO_PERCENT, type CartLine } from './data/menu.ts'
function App() {
// the state lives in the closest common parent of Menu and Cart
const [cart, setCart] = useState<CartLine[]>([])
const [promo, setPromo] = useState('')
function addToCart(id: string) {
setCart((prev) =>
prev.some((line) => line.id === id)
? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
: [...prev, { id, qty: 1 }],
)
}
function changeQty(id: string, delta: number) {
setCart((prev) =>
prev
.map((line) => (line.id === id ? { ...line, qty: line.qty + delta } : line))
.filter((line) => line.qty > 0),
)
}
function removeLine(id: string) {
setCart((prev) => prev.filter((line) => line.id !== id))
}
// derived values are calculated during render, not stored in state
const subtotal = cart.reduce((sum, line) => {
const item = menu.find((i) => i.id === line.id)
return item ? sum + item.price * line.qty : sum
}, 0)
const promoApplied = promo === PROMO_CODE
const discount = promoApplied ? Math.round((subtotal * PROMO_PERCENT) / 100) : 0
const total = subtotal - discount
return (
<main>
<h1>Coffee shop</h1>
<Menu items={menu} onAdd={addToCart} />
<h2>Cart</h2>
<Cart lines={cart} items={menu} onChangeQty={changeQty} onRemove={removeLine} />
<label>
Promo code{' '}
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />
</label>
{promoApplied && <p>Discount {PROMO_PERCENT}%: −{discount} ₸</p>}
<p>
<strong>Total: {total} ₸</strong>
</p>
</main>
)
}
export default App- A single source of truth: each piece of state has one owning component.
Appowns the cart;MenuandCartonly display data and report actions throughonAdd,onChangeQtyandonRemove. MenuandCartare controlled components: their behaviour is fully driven by props. The docs call a component whose local state the parent can't influence uncontrolled.- The cart stores an
idand a quantity, not copies of the items. The docs recommend storing the selected item's ID rather than the object itself: if a price changes in the menu, the cart shows the new one immediately. subtotal,discountandtotalaren't in state: they can be calculated fromcartandpromoon every render. Redundant state that has to be synced by hand is a common source of bugs.
Common mistakes, testing and practice
Mutating an object or array
- Symptom
- the data changed, the screen didn't
- Fix
- create a new object: spread,
map,filter
Stale value
- Symptom
- +3 adds 1;
console.logafter the setter shows the old value - Fix
- an updater
setX(prev => …); keep the new value in a separate variable
onClick={handleClick()}
- Symptom
- a Too many re-renders error: the setter runs during render
- Fix
- pass the function:
onClick={handleClick}oronClick={() => handleClick(id)}
Redundant state
- Symptom
- the total disagrees with the cart
- Fix
- calculate derived values during render
Copying props into state
- Symptom
useState(props.color)ignores new props- Fix
- use the prop directly; if you only need the initial value, name the prop
initialColor
Let's check the behaviour with tests. The project is created as in the previous lesson, with the same Vitest configuration and src/test/setup.ts. You'll also need @testing-library/user-event, which simulates clicks and typing the way a user does.
npm create vite@latest coffee-cart -- --template react-ts
cd coffee-cart
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-eventimport { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { BrokenCounter, Counter } from './components/Counter.tsx'
describe('counters', () => {
it('a regular variable does not trigger a re-render', async () => {
const user = userEvent.setup()
render(<BrokenCounter />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button').textContent).toBe('Broken counter: 0')
})
it('state is a snapshot: +3 with snapshot adds 1, with updater adds 3', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '+3 with snapshot' }))
expect(screen.getByText('Count: 1')).toBeTruthy()
await user.click(screen.getByRole('button', { name: '+3 with updater' }))
expect(screen.getByText('Count: 4')).toBeTruthy()
})
})
describe('cart', () => {
it('adds, changes quantity, removes and totals', async () => {
const user = userEvent.setup()
render(<App />)
const addButtons = screen.getAllByRole('button', { name: 'Add to cart' })
await user.click(addButtons[0]) // Cappuccino
await user.click(addButtons[0])
await user.click(addButtons[2]) // Cheesecake
expect(screen.getByLabelText('Quantity: Cappuccino').textContent).toBe('2')
expect(screen.getByText('Total: 5600 ₸')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Fewer: Cheesecake' }))
expect(screen.queryByLabelText('Quantity: Cheesecake')).toBeNull()
expect(screen.getByText('Total: 3200 ₸')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.getByText('Your cart is empty')).toBeTruthy()
expect(screen.getByText('Total: 0 ₸')).toBeTruthy()
})
it('controlled input: the promo code is upper-cased and applies a discount', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getAllByRole('button', { name: 'Add to cart' })[1]) // Latte
await user.type(screen.getByLabelText('Promo code'), 'autumn')
expect(screen.getByLabelText<HTMLInputElement>('Promo code').value).toBe('AUTUMN')
expect(screen.getByText('Discount 10%: −180 ₸')).toBeTruthy()
expect(screen.getByText('Total: 1620 ₸')).toBeTruthy()
})
})$ npm test
> coffee-cart@0.0.0 test
> vitest run
RUN v5.0.3 …/coffee-cart
Test Files 1 passed (1)
Tests 4 passed (4)npm run build (type check and bundle) passes without errors.State handles what happens in response to user actions. Sometimes, though, a component needs to synchronise with something outside React: a timer, a subscription, a server. That's the subject of the next lesson, useEffect, where we'll also look at when you don't need an Effect at all.
How this was verified5 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.