Hooks and State
Managing component state with useState, the rules of hooks, and useRef for DOM access.
Why state exists
Props let data flow into a component, but props alone can't represent something changing over time as the user interacts with the page — a counter going up, a form field being typed into, a toggle flipping on and off. For that, a component needs its own local, persistent, mutable-feeling data: state.
Plain JavaScript variables don't work for this. Reassigning a normal variable inside a function component doesn't cause React to re-render — the function just runs again from scratch next time something else triggers it, with that variable reset to its initial value. State is React's mechanism for a value that survives across re-renders and, crucially, triggers a re-render whenever it changes.
useState
useState is a Hook — a special function that lets a function component "hook into" React features like state. It returns a pair: the current value, and a function to update it.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0); // 0 is the initial value
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Calling setCount does two things: it updates the value React associates with this piece of state, and it schedules a re-render of the component (and its descendants) so the new value shows up on screen. This is the fundamental React loop: state changes → component re-renders → JSX reflects the new state.
useState(0) — the argument is only used on the component's very first render, to set the initial value. On every render after that, count holds whatever it was most recently set to, ignoring the initial argument entirely.
Updating state based on the previous state
When a new state value depends on the previous one, pass an updater function instead of a plain value. This matters because state updates aren't applied instantly — several setCount(count + 1) calls made in quick succession can end up reading the same stale count:
function Counter() {
const [count, setCount] = useState(0);
function incrementTwice() {
setCount(c => c + 1); // c is guaranteed to be the latest value
setCount(c => c + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementTwice}>+2</button>
</div>
);
}
With setCount(count + 1) called twice back-to-back, both calls would capture the same count from that render and the count would only go up by 1, not 2. The updater-function form (c => c + 1) always receives the truly latest pending value, so both updates apply correctly.
State with objects and arrays
State updates replace the value; they don't merge into it the way this.setState did in old class components. When state is an object or array, always create a new one rather than mutating the existing one:
function ProfileForm() {
const [user, setUser] = useState({ name: "", email: "" });
function updateName(newName) {
setUser({ ...user, name: newName }); // spread the old fields, override name
}
return (
<input
value={user.name}
onChange={e => updateName(e.target.value)}
/>
);
}
setUser({ ...user, name: newName }) builds a brand-new object. Writing user.name = newName; setUser(user) would mutate the existing object in place — React might not detect the change at all, since it compares references, not deep contents.
Rules of hooks
Hooks come with two non-negotiable rules, both enforced by the eslint-plugin-react-hooks linter that ships with most React setups:
- Only call hooks at the top level. Never inside a loop, a condition, or a nested function. This ensures hooks are called in the exact same order on every render, which is how React internally matches each
useStatecall to the right stored value across re-renders. - Only call hooks from React function components or custom hooks. Never from a regular JavaScript function, a class component, or an event handler passed elsewhere.
// Wrong — a hook inside a condition
function Bad({ showExtra }) {
if (showExtra) {
const [extra, setExtra] = useState(0); // breaks the call order between renders
}
// ...
}
// Right — always call the hook; use the condition for what you do with the result
function Good({ showExtra }) {
const [extra, setExtra] = useState(0);
return showExtra ? <p>Extra: {extra}</p> : null;
}
useRef: state's quieter cousin
useRef also gives you a value that persists across re-renders, but with an important difference from useState: updating a ref does not trigger a re-render.
import { useRef, useState } from "react";
function Stopwatch() {
const [, forceTick] = useState(0);
const renderCount = useRef(0);
renderCount.current += 1; // updates immediately, but doesn't re-render anything
return (
<div>
<p>This component has rendered {renderCount.current} times.</p>
<button onClick={() => forceTick(t => t + 1)}>Force re-render</button>
</div>
);
}
The two most common uses of useRef:
1. Accessing a real DOM node directly — for things React's declarative model doesn't cover, like focusing an input or measuring an element's size:
import { useRef } from "react";
function TextInput() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current.focus(); // imperative DOM access
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus the input</button>
</div>
);
}
2. Storing a mutable value that shouldn't cause a re-render when it changes — like a timer ID, a previous value for comparison, or a render counter as above.
useState vs useRef
useState |
useRef |
|
|---|---|---|
| Triggers a re-render on change | Yes | No |
| Value available immediately after setting | Only on the next render | Immediately (.current updates synchronously) |
| Typical use | Data the UI displays or reacts to | DOM node access, timer IDs, values that don't affect what's rendered |
Use useState for anything that should visibly affect the rendered output. Use useRef for anything that needs to persist between renders but shouldn't, by itself, cause the component to re-render.
Common mistakes
- Mutating state directly (
user.name = "x"; setUser(user)) instead of creating a new object/array — React compares by reference and may not notice the change. - Reading
countright after callingsetCount(...)and expecting it to already reflect the new value — state updates are not synchronous within the same render. - Calling a hook conditionally or inside a loop, breaking React's assumption that hooks run in the same order every render.
- Reaching for
useRefwhen the value should actually drive the UI — if changing it should update what's on screen, it needs to be state, not a ref.