State is what a component remembers between renders. Props come from outside and are read-only; state is owned by the component and changing it is what makes the screen update.
Why a variable will not do
function Counter() {
let count = 0; // resets to 0 on every render
return <button onClick={() => count++}>{count}</button>; // and nothing re-renders
}Two separate problems. A local variable is recreated each time the function runs, so it cannot remember anything. And changing it does not tell React that anything happened, so even if it did remember, the button would never redraw.
useState solves both:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}React stores the value outside the function and hands it back on every render. The setter both updates the stored value and schedules a re-render.
The shape of it
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
// ↑ current value ↑ setter ↑ type ↑ initial valueIt returns an array of exactly two elements, which is why you always see array destructuring. The
names are yours; [thing, setThing] is the universal convention and worth keeping.
TypeScript usually infers the type from the initial value — useState(0) is a
number, useState('') is a string. You only need the explicit
generic when the initial value does not describe the full range:
const [size, setSize] = useState<SizeName>('MEDIUM'); // else inferred as string
const [crustId, setCrustId] = useState<string | null>(null); // else inferred as null
const [toasts, setToasts] = useState<ToastMessage[]>([]); // else inferred as never[]
const [quantity, setQuantity] = useState(1); // number — no help neededThat third one bites people: useState([]) infers never[], and then
pushing anything into it is a type error.
Lazy initial state
The initial value is only used on the first render, but the expression that produces it runs on every render. If it is expensive, pass a function instead and React will call it once:
useState(readFromLocalStorage()); // runs on every render, result thrown away after the first
useState(() => readFromLocalStorage()); // runs onceThe rules of hooks
Two rules, and they are not stylistic — React identifies your state by call order, not by name. It has nothing else to go on.
- Only call hooks at the top level of a component. Never inside a condition, a loop, a nested function or after an early return.
- Only call them from a React function — a component, or another hook.
// WRONG — the number of hook calls now changes between renders, and React loses track.
if (product) {
const [size, setSize] = useState('MEDIUM');
}
// Right — call it unconditionally, put the condition in the value.
const [size, setSize] = useState<SizeName>('MEDIUM');
const isPizza = product?.type === 'PIZZA';The error message is "Rendered fewer hooks than expected" or "Rendered more hooks than during the previous render". When you see it, look for a hook after an early return — that is the usual cause and it is easy to miss.
One state variable or several?
Split them when they change independently. The pizza builder has four, and every one moves on its own:
const [size, setSize] = useState<SizeName>('MEDIUM');
const [crustId, setCrustId] = useState<string | null>(null);
const [selectedToppingIds, setSelectedToppingIds] = useState<string[]>([]);
const [quantity, setQuantity] = useState(1);Group them when they always change together, or when one is meaningless without the other. Once a group of related values grows past three or four and the updates start depending on each other, a reducer is usually the better answer — see useReducer.
Do not store what you can derive
This is the most valuable habit in this post. Anything computable from existing state or props is not state:
// WRONG — two sources of truth that can disagree, plus an effect to keep them in step.
const [items, setItems] = useState<CartItem[]>([]);
const [itemCount, setItemCount] = useState(0);
// Right — one source of truth, recomputed on render.
const [items, setItems] = useState<CartItem[]>([]);
const itemCount = items.reduce((count, item) => count + item.quantity, 0);The whole cart total is computed this way, never stored:
export function calculateTotals(items: CartItem[], orderType: 'DELIVERY' | 'CARRYOUT'): CartTotals {
const subtotal = round2(items.reduce((sum, item) => sum + lineTotal(item), 0));
const deliveryFee = orderType === 'DELIVERY' && subtotal > 0 ? DELIVERY_FEE : 0;
const tax = round2(subtotal * TAX_RATE);
return {
subtotal,
tax,
deliveryFee,
total: round2(subtotal + tax + deliveryFee),
itemCount: items.reduce((count, item) => count + item.quantity, 0),
};
}Stored totals go stale. Derived totals cannot. If the derivation ever gets expensive, wrap it in useMemo — but derive first and measure before optimising.
Prefer fewer, better-shaped variables
The menu page could have had modalOpen and selectedProduct. It has one:
// `null` means the modal is closed. One piece of state, not two.
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
/* … */
<PizzaBuilderModal product={selectedProduct} onHide={() => setSelectedProduct(null)} />Two booleans-and-values that must agree is a state that can be wrong —
modalOpen: true, selectedProduct: null is representable and meaningless. Collapsing them
makes that impossible.
State is per component instance
Render <ProductCard /> fourteen times and there are fourteen independent copies
of its state. This is usually exactly what you want — and occasionally the problem, when two
components need to agree.
Lifting state up
When two components need the same value, it belongs in their closest common parent. The cart
drawer's open/closed flag lives in App, because the navbar opens it and the drawer
consumes it, and App is the nearest thing above both:
export default function App() {
// The drawer's open/closed state lives here because both the navbar (which opens it) and the
// drawer itself need it. This is "lifting state up" — the cart CONTENTS are in context, but
// this piece of purely-visual state is not worth putting there.
const [cartOpen, setCartOpen] = useState(false);
return (
<div className="d-flex flex-column min-vh-100">
<AppNavbar onOpenCart={() => setCartOpen(true)} />
{/* … */}
<CartDrawer show={cartOpen} onHide={() => setCartOpen(false)} />
</div>
);
}The state moves up; a value and a function to change it move back down as props. Neither child owns it, and they cannot disagree.
Lifting has a limit. When the common parent is five levels up and three intermediate components
would carry a prop they never read, that is prop drilling, and
Context is the tool. Note that this app uses both: the
cart's contents are in context because half the app needs them, while whether the drawer is
open is lifted into App, because only two components care.
Next
Updating State Correctly — the part that surprises people.