Step 14 of 21
State & effects
Make components interactive with useState, keep state minimal and immutable, and use useEffect only to sync with outside systems.
Props let a parent configure a component, but a component also needs to remember things of its own: what the user typed, whether a menu is open, which tab is selected. That is state. And sometimes a component needs to keep something outside React in sync, such as a timer, a browser API or a third-party widget. That is what effects are for. This lesson teaches both, and, just as importantly, how to recognise when you need neither.
Why state exists
A local variable does not work for remembering things:
function Counter() {
let count = 0
return <button onClick={() => count++}>Clicked {count} times</button>
}Clicking changes count, but nothing re-renders, and if something else triggered a render, the function would run again and reset count to 0. You need a value that survives between renders and that tells React to render again when it changes. That is useState:
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button type="button" onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}useState(0) returns the current value and a setter. Calling the setter schedules a re-render, and on that render useState returns the new value. Each instance of <Counter /> on the page has its own independent state.
useState is a Hook. Hooks are functions starting with use, and they have two rules: call them only at the top level of a component or another Hook (never inside conditions, loops or nested functions), and only from React functions. React identifies each Hook by its call order, so that order must be the same on every render.
State is a snapshot
Here is the most common surprise in React:
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
console.log(count) // still 0
}After one click the counter shows 1, not 3. During a render, count is a constant: the value React gave this render. All three calls compute 0 + 1. The setter does not change the variable you already have; it asks React for a new render with a new value. React also batches updates, so all three are processed together in a single re-render after the handler finishes.
When the next state depends on the previous one, pass an updater function. React queues the updaters and runs each one with the latest pending value:
function handleClick() {
setCount((c) => c + 1)
setCount((c) => c + 1)
setCount((c) => c + 1) // the counter now shows 3
}React decides whether anything changed using Object.is on the old and new value. If you set the value it already has, React bails out without re-rendering the children.
Updating objects and arrays
Because React compares by reference, you must replace objects and arrays in state, never mutate them:
const [todos, setTodos] = useState<Todo[]>([])
// Wrong: same array, so React sees no change and skips the render
todos.push(newTodo)
setTodos(todos)
// Right: a new array
setTodos([...todos, newTodo])| Goal | Immutable pattern |
|---|---|
| Add to an array | [...items, item] |
| Remove from an array | items.filter((t) => t.id !== id) |
| Update one item | items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) |
| Change one object field | { ...form, email: value } |
| Change a nested field | { ...user, address: { ...user.address, city } } |
Avoid push, splice, sort and reverse directly on state arrays; copy first or use toSorted(), toReversed() and with(), which return new arrays.
Where state should live
Good React code keeps state minimal: store the smallest set of values from which everything else can be calculated.
Derive instead of storing
// Redundant: fullName can drift out of sync with firstName and lastName
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
// Better: compute it during rendering
const fullName = `${firstName} ${lastName}`.trim()The same goes for filtered lists, counts, totals and validation results. If you can compute it from props or other state, compute it.
Lift state up to share it
When two components need the same data, move the state to their closest common parent and pass it down as props, along with functions to change it:
function FilterableList({ items }: { items: string[] }) {
const [query, setQuery] = useState('')
const visible = items.filter((item) => item.toLowerCase().includes(query.toLowerCase()))
return (
<>
<SearchBox query={query} onQueryChange={setQuery} />
<Results items={visible} />
</>
)
}
function SearchBox({ query, onQueryChange }: { query: string; onQueryChange: (q: string) => void }) {
return (
<input
aria-label="Search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
/>
)
}The input is controlled: its value comes from state, and every keystroke updates that state. There is now a single source of truth for query.
Reset state with a key
React keeps a component's state as long as the same component renders at the same position in the tree. To give it fresh state, for example when switching between user profiles, change its key: <ProfileForm key={userId} userId={userId} />. A new key means a new component instance.
Effects: synchronising with the outside world
Some code has to run because a component is on screen, not because the user did something: connecting to a chat server, subscribing to a browser event, starting a timer. That is a side effect, and side effects cannot run during rendering, which must stay pure. useEffect runs your code after React has updated the DOM:
import { useEffect, useState } from 'react'
export function OnlineStatus() {
const [online, setOnline] = useState(navigator.onLine)
useEffect(() => {
function update() {
setOnline(navigator.onLine)
}
window.addEventListener('online', update)
window.addEventListener('offline', update)
return () => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
}
}, [])
return <p>{online ? 'Online' : 'Offline'}</p>
}The function you return is the cleanup. React runs it before the effect runs again and when the component is removed. Every subscription, listener, timer or connection you start in an effect needs a matching cleanup, or it leaks.
The dependency array
The second argument controls when the effect re-runs:
| Dependencies | Effect runs |
|---|---|
| omitted | after every render |
[] | after the first render only (cleanup on unmount) |
[roomId, serverUrl] | after the first render and whenever any listed value changed |
Every reactive value the effect reads (props, state and anything computed from them) must be listed. You do not choose dependencies; your code does. The react-hooks/exhaustive-deps lint rule, included in Vite's React template, checks this for you.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
// ...
}When roomId changes, React disconnects from the old room (cleanup with the old roomId) and connects to the new one.
Stale closures
An effect sees the values from the render that created it. This interval is broken:
useEffect(() => {
const id = setInterval(() => setSeconds(seconds + 1), 1000)
return () => clearInterval(id)
}, []) // seconds is always 0 inside, so the display sticks at 1Listing seconds as a dependency would work but would recreate the interval every second. The cleaner fix is an updater, which needs no outside value: setSeconds((s) => s + 1).
When you need neither
Effects are an escape hatch for talking to systems outside React. Many effects in real codebases should not exist; they add an extra render, make data flow hard to follow and cause bugs. Before writing one, check this list:
- Transforming data for display? Calculate it during rendering. An effect that sets
filteredItemswheneveritemschanges is just a slower, buggier way to writeconst filteredItems = items.filter(...). If the calculation is genuinely expensive, wrap it inuseMemo(() => ..., [items]), or let the React Compiler memoise it for you. - Responding to a user action? Put the code in the event handler. "Show a toast when the user clicks Buy" is about the click, not about the component being displayed.
- Resetting state when a prop changes? Use a
key, as shown above. - Telling a parent about a change? Call the parent's callback in the same event handler that changed the state.
// Avoid: an effect reacting to state that an event set
useEffect(() => {
if (submitted) showToast('Order placed')
}, [submitted])
// Prefer: do it where it happens
async function handleBuy() {
await placeOrder(cart)
showToast('Order placed')
}Data fetching is a legitimate effect use, but doing it correctly by hand involves race conditions and caching. The next lesson covers that, and the library that does it for you.
Practice
Build a Stopwatch component with:
- A display of elapsed time in seconds with one decimal place, such as
12.3s. - A Start/Stop button that toggles the stopwatch and a Reset button that sets it to zero.
- The browser tab title showing the elapsed time while the stopwatch runs.
- No leaked intervals: stopping, or removing the component, must clear the timer.
Show solution
import { useEffect, useState } from 'react'
export function Stopwatch() {
const [running, setRunning] = useState(false)
const [tenths, setTenths] = useState(0)
// Sync with a browser timer while running
useEffect(() => {
if (!running) return
const id = setInterval(() => setTenths((t) => t + 1), 100)
return () => clearInterval(id)
}, [running])
// Derived during render, not stored in state
const label = `${(tenths / 10).toFixed(1)}s`
// Sync with the document title
useEffect(() => {
if (!running) return
const previous = document.title
document.title = label
return () => {
document.title = previous
}
}, [running, label])
return (
<div>
<p>{label}</p>
<button type="button" onClick={() => setRunning((r) => !r)}>
{running ? 'Stop' : 'Start'}
</button>
<button
type="button"
onClick={() => {
setRunning(false)
setTenths(0)
}}
>
Reset
</button>
</div>
)
}The interval effect depends only on running, so it is created once per start and cleared on stop or unmount. The updater t => t + 1 avoids a stale closure. label is derived, so there is no state to keep in sync. Reset is a user action, so it lives in a click handler rather than an effect. For a production stopwatch you would store a start timestamp and compute elapsed time from Date.now(), since intervals can drift when a tab is in the background.
Key takeaways
useStategives a component memory and triggers a re-render when you set a new value.- State is a snapshot per render; use updater functions when the next value depends on the previous one.
- Never mutate state: replace objects and arrays with new copies.
- Keep state minimal: derive values during rendering and lift shared state to the closest common parent.
useEffectsynchronises with systems outside React; list every dependency and always clean up.- Event-driven logic goes in event handlers, not effects; most "derived state" effects should be deleted.
Check yourself
Quiz
0/4 correct · 4 left
1.`count` is 0. A click handler calls `setCount(count + 1)` three times in a row. What does the component show after the click?
2.A component calls `items.push(newItem); setItems(items)`. Why does the list not update?
3.In development with StrictMode, a component with `useEffect(() => { console.log('setup'); return () => console.log('cleanup') }, [])` mounts. What is logged?
4.A component receives `price` and `quantity` props and must show their total. What is the best approach?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.