State & Lifecycle
useState, useEffect, component lifecycle, controlled vs uncontrolled
State is data managed by a component that, when changed, causes React to re-render. useState is the primary hook for local state. useEffect handles synchronisation with external systems. Understanding when and why React re-renders — and how to control it — is fundamental to building correct React apps.
Key Points
- useState: const [value, setValue] = useState(initial) — value is read-only; always update via setValue
- State updates are asynchronous and batched — reading state immediately after setState returns the old value; use functional update form
- Functional update: setValue(prev => prev + 1) — use when new state depends on old state, safe in async handlers
- Each useState call creates isolated state — components can have as many as needed
- Derived state: do NOT sync props to state with useEffect — compute it directly in the render function
- useEffect(fn, deps): run side effects after render — data fetching, subscriptions, manual DOM manipulation
- useEffect cleanup: return a cleanup function to cancel subscriptions, timers, or abort fetch on unmount
- Dependency array: [] runs once on mount; [a, b] runs when a or b changes; omit → runs after every render (rarely correct)
- Controlled vs uncontrolled: controlled = React owns value via useState; uncontrolled = DOM owns value via useRef
React state: functional updates, derived state without useEffect, fetch with AbortController cleanup
import { useState, useEffect } from 'react';
// Functional update — safe when new value depends on previous
function Counter() {
const [count, setCount] = useState(0);
const [history, setHistory] = useState<number[]>([]);
function increment() {
setCount(prev => prev + 1); // safe functional update
setHistory(prev => [...prev, prev.length + 1]); // append
}
return <button onClick={increment}>Count: {count}</button>;
}
// Derived state — compute in render, don't sync to state
function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
// Derive from props + state — no useEffect needed
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</>
);
}
// useEffect — data fetching with cleanup (AbortController)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e.message); })
.finally(() => setLoading(false));
return () => controller.abort(); // cleanup: cancel on userId change or unmount
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return user ? <UserCard user={user} /> : null;
}Real-World Example
The most common React bug: reading state immediately after setState and expecting the new value. setState is asynchronous — the component re-renders after the current event handler completes. If you need the new value immediately, use functional update form or compute it into a local variable: const newCount = count + 1; setCount(newCount); doSomethingWith(newCount).