Preface
This guide is written for you as the trainer. It covers every topic in the three-day intermediate React track with enough depth to handle follow-up questions confidently. Each section explains the concept plainly, shows focused code examples, flags common misconceptions, and includes Q&A blocks drawn from real classroom questions.
The guide is structured so you can add new topics at any time without disrupting existing content. To extend: add a new section with a chapter-label and h1, then add the sidebar links under a new sec label.
Read each chapter the evening before that training day. The Q&A sections cover questions trainees almost always ask — reviewing them ensures nothing catches you off guard.
Hooks in Depth
Hooks are functions that let you "hook into" React state and lifecycle from function components. They were introduced in React 16.8 as a replacement for class component lifecycle methods and to enable reuse of stateful logic across components without wrapping in higher-order components or render props.
This chapter covers the rules that govern all hooks, a deeper look at useState, then useEffect, useRef, useMemo, useCallback, and ends with useContext, useReducer, and custom hooks.
1.1 Rules of Hooks
React enforces two hard rules for hooks. These are not conventions — violating them silently corrupts state and produces bugs that are very difficult to trace.
Rule 1: Only call hooks at the top level
Never call hooks inside loops, conditions, nested functions, or after an early return. React maintains hook state as an ordered list internally. Every render must call the same hooks in the same order. If a hook call is conditional, the order can shift between renders and React loses track of which state belongs to which hook.
// WRONG — hook inside a condition
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const [name, setName] = useState(''); // ← order breaks if isLoggedIn changes
}
// ...
}
// WRONG — hook after an early return
function Profile({ user }) {
if (!user) return null; // ← early return before hooks
const [editing, setEditing] = useState(false); // hook never reached on first render if user is null
}
// CORRECT
function Profile({ isLoggedIn, user }) {
const [name, setName] = useState('');
const [editing, setEditing] = useState(false);
if (!user) return null; // early returns go AFTER all hooks
if (!isLoggedIn) return ...; // conditions go AFTER all hooks
}
How React tracks hooks internally
React stores hook state in a linked list attached to the fiber (internal component instance). On every render, React walks that list in order, pairing each hook call to the corresponding node. If the call count or order differs between renders, the pairing breaks. This is why the rules exist — they are not arbitrary; they reflect how the internals work.
Rule 2: Only call hooks from React functions
Hooks can only be called from:
- React function components
- Custom hooks (functions whose name starts with
use)
They cannot be called from plain utility functions, class components, event handlers defined outside components, or third-party non-React code.
The ESLint plugin
Install eslint-plugin-react-hooks in every React project. It enforces both rules statically and catches violations before runtime. The two rules it enforces are react-hooks/rules-of-hooks and react-hooks/exhaustive-deps. Both should be set to "error".
// .eslintrc
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
1.2 useState (Revisited)
Your trainees have seen useState already. This section goes deeper: functional updates, lazy initialisation, and the common pitfall of treating state updates as synchronous.
State updates are asynchronous and batched
Calling a state setter does not update the variable immediately. React schedules a re-render. Reading state right after calling the setter still gives the old value.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count is still 0 in all three lines above
// result: count becomes 1, not 3
};
}
Functional updates
When new state depends on the previous value, pass a function to the setter. React guarantees this function always receives the latest state value, even if multiple updates are batched.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// result: count becomes 3 — each updater sees the result of the previous one
};
}
Use functional updates whenever the new state value is derived from the previous one: counters, toggles, appending to arrays.
// Toggle pattern
setIsOpen(prev => !prev);
// Append to array
setItems(prev => [...prev, newItem]);
// Remove from array
setItems(prev => prev.filter(item => item.id !== id));
// Update one item in array
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...updates } : item
));
Lazy initialisation
If the initial state value is expensive to compute (reading from localStorage, parsing a large dataset), pass a function as the argument to useState. React calls the function once on mount and ignores it on re-renders.
// INEFFICIENT — reads localStorage on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
);
// CORRECT — lazy initialiser, called only once
const [settings, setSettings] = useState(() =>
JSON.parse(localStorage.getItem('settings') || '{}')
);
State with objects and arrays
React state is immutable by convention. Never mutate state objects directly — always return a new object or array. React uses reference equality to detect changes; mutating an object and setting the same reference will not trigger a re-render.
// WRONG — mutates existing state
const [user, setUser] = useState({ name: 'Alice', age: 30 });
const handleBirthday = () => {
user.age += 1; // mutates directly
setUser(user); // same reference — React sees no change, no re-render
};
// CORRECT — new object
const handleBirthday = () => {
setUser(prev => ({ ...prev, age: prev.age + 1 }));
};
// WRONG — mutates array
const [items, setItems] = useState([1, 2, 3]);
items.push(4); // mutates directly
setItems(items); // same reference — no re-render
// CORRECT — new array
setItems(prev => [...prev, 4]);
{ ...obj }) does not deep-clone. Nested objects are still shared by reference. For deeply nested state, either restructure to keep state flat, or use a library like Immer which lets you write "mutating" code that is actually immutable under the hood.
1.3 useEffect Hook
useEffect lets you synchronise a component with an external system — a server, the browser DOM, a timer, or a third-party library. It runs after the browser has painted, so it never blocks the UI.
Three patterns
| Dependency array | When the effect runs | Use for |
|---|---|---|
useEffect(() => { ... }) — omitted | After every render | Syncing with something that changes every render (rare) |
useEffect(() => { ... }, []) — empty | Once after first render | Mount-only setup: subscriptions, one-time fetches |
useEffect(() => { ... }, [a, b]) | After mount + when a or b changes | Fetching data based on props, syncing to external state |
// Pattern 1: runs after every render
useEffect(() => {
document.title = `You clicked ${count} times`;
});
// Pattern 2: runs once on mount
useEffect(() => {
const socket = new WebSocket('wss://example.com');
return () => socket.close();
}, []);
// Pattern 3: runs when userId changes
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
Async inside effects
useEffect must not be declared async. An async function returns a Promise, and React expects the callback to return either nothing or a cleanup function. The solution is to define an async function inside the effect and call it immediately.
// WRONG — async effect
useEffect(async () => {
const data = await fetchUser(userId); // async effect returns a Promise
setUser(data);
}, [userId]);
// CORRECT — async function inside effect
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await fetchUser(userId);
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err.message);
}
};
load();
return () => { cancelled = true; };
}, [userId]);
Using AbortController
For fetch requests specifically, AbortController is the modern cancellation mechanism. It cancels the in-flight network request rather than just suppressing the state update.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') return; // intentionally cancelled
setError(err.message);
});
return () => controller.abort(); // cancel the request on cleanup
}, [userId]);
1.4 Dependency Array
The dependency array is the most common source of bugs in React effects. Understanding it deeply prevents entire categories of problems.
Primitives vs reference types
React compares dependencies using Object.is, which is strict equality. Primitives (strings, numbers, booleans) compare by value. Objects and arrays compare by reference.
// PROBLEM: options is a new object literal on every render
function Component({ userId }) {
useEffect(() => {
fetchUser(userId, options);
}, [userId, options]); // options is always a new reference → infinite loop
const options = { includeProfile: true }; // new object every render
}
// SOLUTION 1: Move the object outside the component (if static)
const OPTIONS = { includeProfile: true }; // created once
function Component({ userId }) {
useEffect(() => {
fetchUser(userId, OPTIONS);
}, [userId]); // OPTIONS never changes, so not needed in deps
}
// SOLUTION 2: Memoize it with useMemo (if it depends on props)
function Component({ userId, includeProfile }) {
const options = useMemo(
() => ({ includeProfile }),
[includeProfile]
);
useEffect(() => {
fetchUser(userId, options);
}, [userId, options]); // options only changes when includeProfile changes
}
Functions as dependencies
Functions defined inside a component are recreated on every render — they are new references each time. If an effect depends on a function, that effect re-runs on every render.
// PROBLEM: fetchData is recreated on every render
function Component({ userId }) {
const fetchData = () => fetch(`/api/${userId}`); // new reference every render
useEffect(() => {
fetchData();
}, [fetchData]); // runs every render because fetchData is always new
}
// SOLUTION 1: Move the function inside the effect (most common fix)
function Component({ userId }) {
useEffect(() => {
const fetchData = () => fetch(`/api/${userId}`);
fetchData();
}, [userId]); // now depends only on userId, which is a primitive
}
// SOLUTION 2: Memoize with useCallback (when the function is used in multiple places)
function Component({ userId, onLoad }) {
const fetchData = useCallback(() => {
return fetch(`/api/${userId}`).then(r => r.json()).then(onLoad);
}, [userId, onLoad]);
useEffect(() => {
fetchData();
}, [fetchData]);
}
The exhaustive-deps linter rule
The react-hooks/exhaustive-deps rule warns whenever a value used inside an effect is missing from the dependency array. The correct response to this warning is to add the missing dependency — not to suppress the warning or add // eslint-disable-line. Suppressing it hides real bugs.
useMemo and useCallback become genuinely motivated, not abstract.
1.5 Cleanup Functions
Every effect that creates a resource must return a function that releases it. The cleanup function runs in two situations: before the next effect execution (when dependencies change), and when the component unmounts.
useEffect(() => {
// SETUP — create the resource
const resource = createSomething();
return () => {
// CLEANUP — release the resource
resource.destroy();
};
}, [dep]);
Common things that need cleanup
| Resource | Setup | Cleanup |
|---|---|---|
| Event listener | addEventListener | removeEventListener |
| Timer | setInterval / setTimeout | clearInterval / clearTimeout |
| WebSocket | new WebSocket() | socket.close() |
| Observable/subscription | subscribe() | unsubscribe() |
| Fetch request | fetch(url, { signal }) | controller.abort() |
| Intersection Observer | observer.observe(el) | observer.disconnect() |
Subscription pattern
function OnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []); // empty: these events don't depend on any state/props
return <span>{isOnline ? 'Online' : 'Offline'}</span>;
}
Stale closures
A stale closure occurs when an effect captures a variable from its creation scope, but that variable later changes in an outer render. The effect still holds the old value because closures in JavaScript capture by reference to the scope, not a live binding to state.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // STALE: count is captured at 0 and never updates
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — count never updates inside here
return <p>{count}</p> // stays 1 forever
}
// FIX: use functional update — no need to capture count at all
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // reads latest value via updater
}, 1000);
return () => clearInterval(id);
}, []);
1.6 useRef Hook
useRef(initialValue) returns { current: initialValue }. The object is stable — the same reference on every render. Mutating .current does not cause a re-render.
DOM access
// Auto-focus on mount
function SearchBar() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="search" />;
}
// Measure element dimensions
function MeasuredBox() {
const boxRef = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const { width, height } = boxRef.current.getBoundingClientRect();
setSize({ width, height });
}, []);
return (
<div ref={boxRef}>
Width: {size.width}px, Height: {size.height}px
</div>
);
}
// Scroll to bottom (e.g. chat window)
function ChatWindow({ messages }) {
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="chat">
{messages.map(m => <p key={m.id}>{m.text}</p>)}
<div ref={bottomRef} />
</div>
);
}
Mutable values without re-rendering
// Store a timer ID
function Debounced({ onSearch }) {
const timerRef = useRef(null);
const handleChange = (e) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
onSearch(e.target.value);
}, 300);
};
return <input onChange={handleChange} />;
}
// Track whether the component is mounted
function SafeAsync() {
const isMounted = useRef(true);
const [data, setData] = useState(null);
useEffect(() => {
isMounted.current = true;
fetchData().then(d => {
if (isMounted.current) setData(d);
});
return () => { isMounted.current = false; };
}, []);
}
Storing previous values
A common pattern is to store the previous value of a prop or state to compare with the current value.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // runs after render, so ref holds previous value during render
});
return ref.current;
}
function Counter({ count }) {
const prevCount = usePrevious(count);
return (
<p>
Current: {count}, Previous: {prevCount}
</p>
);
}
1.7 useMemo & useCallback
useMemo — memoize a computed value
// Expensive computation — only re-runs when list or filter changes
function FilteredList({ list, filter, sortBy }) {
const processed = useMemo(() => {
console.log('Computing filtered list...');
return list
.filter(item => item.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
}, [list, filter, sortBy]);
return <ul>{processed.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
useCallback — memoize a function reference
function TodoList() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
// Stable reference — only changes if setTodos changes (it never does)
const addTodo = useCallback((text) => {
setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
}, []);
// Stable reference — only changes if filter changes
const toggleTodo = useCallback((id) => {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
}, []);
return (
<>
<AddTodoForm onAdd={addTodo} /> {/* won't re-render due to addTodo */}
<TodoItem onToggle={toggleTodo} /> {/* won't re-render due to toggleTodo */}
</>
);
}
When to use — decision guide
| Situation | Use | Reason |
|---|---|---|
| Filtering/sorting a large array | useMemo | Avoids recomputing on unrelated re-renders |
| Passing a callback to a memoized child | useCallback | Keeps the child's props stable |
| Creating an object passed as dep to useEffect | useMemo | Prevents effect from re-running every render |
| A function used inside useEffect deps | useCallback | Stabilises the function reference |
| Simple string/number derivation | Neither | Cheaper to just compute it |
| Small component trees | Neither | Memoization overhead outweighs savings |
React.memo
React.memo is a higher-order component that prevents a component from re-rendering if its props have not changed (shallow comparison). It is most effective when combined with useCallback for callback props.
// Child only re-renders if name or onClick changes
const UserCard = React.memo(({ name, onClick }) => {
console.log('UserCard rendered');
return <div onClick={onClick}>{name}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
const [users, setUsers] = useState(['Alice', 'Bob']);
// Without useCallback, handleClick is a new function on every render
// → UserCard would re-render even though name/onClick haven't meaningfully changed
const handleClick = useCallback((name) => {
console.log(name, 'clicked');
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{users.map(name =>
<UserCard key={name} name={name} onClick={handleClick} />
)}
</>
);
}
1.8 Custom Hooks
A custom hook is a function whose name starts with use and which calls other hooks inside it. Custom hooks extract stateful logic so it can be shared and tested independently, without changing component hierarchy.
// useFetch — reusable data fetching hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) { setData(data); setLoading(false); }
})
.catch(err => {
if (!cancelled) { setError(err.message); setLoading(false); }
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}
// useLocalStorage — persist state to localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch { return initialValue; }
});
const setStoredValue = useCallback((newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}, [key]);
return [value, setStoredValue];
}
// useDebounce — delay reacting to rapid value changes
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// useWindowSize — track viewport dimensions
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
useFetch have completely independent loading/data/error state.
1.9 useContext
useContext lets a component read a context value without passing it as a prop through every intermediate component. It is the solution to "prop drilling" — passing the same data through many layers of components that don't need it themselves.
// 1. Create the context
const ThemeContext = React.createContext('light');
// 2. Provide the value high in the tree
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}
// 3. Consume anywhere in the tree — no props needed
function Button() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
style={{ background: theme === 'dark' ? '#333' : '#fff' }}
onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
>
Toggle theme
</button>
);
}
UserContext from ThemeContext) or use useMemo to stabilise the context value object.
1.10 useReducer
useReducer is an alternative to useState for complex state logic. It takes a reducer function and an initial state, and returns the current state and a dispatch function. It is particularly useful when the next state depends on the previous state in complex ways, or when multiple state variables are closely related.
// Reducer — pure function: (state, action) => newState
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
)
};
}
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case 'CLEAR':
return { ...state, items: [] };
default:
return state;
}
}
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
return (
<div>
{cart.items.map(item => (
<div key={item.id}>
{item.name} x{item.qty}
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
Remove
</button>
</div>
))}
<button onClick={() => dispatch({ type: 'CLEAR' })}>Clear cart</button>
</div>
);
}
| Situation | Prefer |
|---|---|
| Simple, independent values | useState |
| Multiple related fields that change together | useReducer |
| Next state depends on previous in complex ways | useReducer |
| You want to test state logic in isolation | useReducer — reducer is a pure function, easy to unit test |
| State transitions with named actions (like Redux) | useReducer |
Q & A — Hooks
setState schedules a re-render. The current variable binding holds the value from the current render — it doesn't live-update. To read state after setting it, you need to wait for the next render. If new state depends on old state, always use the functional updater form: setState(prev => prev + 1).useReducer when state logic becomes complex enough that multiple useState calls need to be updated together, or when the next state depends on the previous in non-trivial ways. It also makes state transitions explicit and named (via action types), which improves readability. A good rule of thumb: if your useState state is an object with 3+ fields that change together, consider useReducer.setState(prev => ...)), or to include the variable in the dependency array so the effect re-runs with the fresh value.useRef returns the same object on every render — it persists across re-renders. createRef creates a brand new ref object on every call. Inside function components, always use useRef. createRef is for class components, where it was called in the constructor and stored on this. Using createRef in a function component loses the ref value on every render.useFetch('/api/users') each have independent loading, data, and error state. If you want genuinely shared state, use Context or an external state manager like Zustand or Redux.useCallback is only useful when the function is passed as a prop to a component wrapped in React.memo, or when the function appears in a useEffect dependency array. In all other cases, wrapping with useCallback adds overhead without any benefit — you're paying the cost of memoization for no gain. Profile first, optimise second.Forms and Validation
Forms are where most real-world React complexity lives. This day covers controlled and uncontrolled inputs in depth, handling every input type, building robust validation patterns, extracting logic into custom hooks, async form submission, and when to reach for a form library.
2.1 Controlled vs Uncontrolled Forms
Controlled components — in depth
In a controlled component, React state is the single source of truth. The input's value is always driven by state, and every change goes through a handler that updates state. The input can never diverge from what is in state.
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value} // state drives the display
onChange={(e) => setValue(e.target.value)} // every keystroke updates state
/>
);
}
// What React guarantees: value in state === value displayed
The consequence: you always know what is in every field. You can validate on every keystroke, conditionally disable the submit button, format the input in real time, and synchronise fields with each other.
// Real-time formatting: phone number
function PhoneInput() {
const [value, setValue] = useState('');
const format = (raw) => {
const digits = raw.replace(/\D/g, '').slice(0, 10);
if (digits.length < 4) return digits;
if (digits.length < 7) return `(${digits.slice(0,3)}) ${digits.slice(3)}`;
return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`;
};
return (
<input
type="tel"
value={value}
onChange={e => setValue(format(e.target.value))}
placeholder="(555) 555-5555"
/>
);
}
Uncontrolled components — in depth
In an uncontrolled component, the DOM owns the value. React reads it via a ref on demand. React does not re-render on every keystroke, so these can be more performant for very large forms where re-renders are costly.
function UncontrolledForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = {
name: nameRef.current.value,
email: emailRef.current.value,
};
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} name="name" defaultValue="" />
<input ref={emailRef} name="email" defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
// Alternative: use the native FormData API (no refs needed)
function FormDataExample() {
const handleSubmit = (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
console.log(data); // { name: '...', email: '...' }
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" />
<button type="submit">Submit</button>
</form>
);
}
When to use each
| Requirement | Controlled | Uncontrolled |
|---|---|---|
| Real-time validation | ✓ | ✗ (submit only) |
| Conditionally disable submit | ✓ | Awkward |
| Format input as user types | ✓ | ✗ |
| Dependent fields (field B changes when A changes) | ✓ | ✗ |
| File inputs | ✗ (React cannot set file value) | ✓ |
| Integrating third-party rich text editors | Difficult | ✓ |
| Very large forms where re-render cost is visible | Manageable | ✓ (React Hook Form approach) |
2.2 Handling Multiple Inputs
The computed property key pattern ([name]: value) lets a single handler control every text-like input as long as the name attribute matches the state key.
function RegistrationForm() {
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<form>
<input name="firstName" value={form.firstName} onChange={handleChange} />
<input name="lastName" value={form.lastName} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} type="email" />
<input name="password" value={form.password} onChange={handleChange} type="password" />
</form>
);
}
Select and Radio inputs
// Select (dropdown)
function RoleSelect() {
const [role, setRole] = useState('viewer');
return (
<select name="role" value={role} onChange={e => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
);
}
// Radio group
function PlanSelector() {
const [plan, setPlan] = useState('free');
return (
<fieldset>
{['free', 'pro', 'enterprise'].map(p => (
<label key={p}>
<input
type="radio"
name="plan"
value={p}
checked={plan === p}
onChange={e => setPlan(e.target.value)}
/>
{p}
</label>
))}
</fieldset>
);
}
Checkbox arrays
When multiple checkboxes map to an array of selected values (not a single boolean), the handler needs to add or remove values from the array.
function PermissionsForm() {
const [permissions, setPermissions] = useState([]);
const handleCheckbox = (e) => {
const { value, checked } = e.target;
setPermissions(prev =>
checked
? [...prev, value] // add when checked
: prev.filter(p => p !== value) // remove when unchecked
);
};
const allPerms = ['read', 'write', 'delete', 'admin'];
return (
<fieldset>
{allPerms.map(perm => (
<label key={perm}>
<input
type="checkbox"
value={perm}
checked={permissions.includes(perm)}
onChange={handleCheckbox}
/>
{perm}
</label>
))}
<p>Selected: {permissions.join(', ')}</p>
</fieldset>
);
}
2.3 Form Validation Basics
Validation is about giving users accurate feedback at the right time — not too early (before they've had a chance to type), not too late (only after submit).
Submit-only validation (simplest)
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = (values) => {
const errs = {};
if (!values.email) errs.email = 'Email is required.';
else if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email format.';
if (!values.password) errs.password = 'Password is required.';
else if (values.password.length < 8) errs.password = 'Minimum 8 characters.';
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate(form);
setErrors(errs);
if (Object.keys(errs).length === 0) {
submitToServer(form);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input name="email" value={form.email}
onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
{errors.email && <p style={{color:'red'}}>{errors.email}</p>}
</div>
<div>
<input name="password" type="password" value={form.password}
onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
{errors.password && <p style={{color:'red'}}>{errors.password}</p>}
</div>
<button type="submit">Login</button>
</form>
);
}
On-change validation
Validate on change after the first submit attempt. Before that, only validate on blur (when the field loses focus). This is the best user experience: no premature errors while typing, but instant feedback when correcting.
function SignupForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const validate = (values) => {
const errs = {};
if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email.';
if (values.password.length < 8) errs.password = 'Min 8 characters.';
return errs;
};
const handleChange = (e) => {
const updated = { ...form, [e.target.name]: e.target.value };
setForm(updated);
if (hasSubmitted) setErrors(validate(updated)); // re-validate only after first submit
};
const handleBlur = (e) => {
// Validate the field that just lost focus
const fieldErrors = validate(form);
setErrors(prev => ({ ...prev, [e.target.name]: fieldErrors[e.target.name] }));
};
const handleSubmit = (e) => {
e.preventDefault();
setHasSubmitted(true);
const errs = validate(form);
setErrors(errs);
if (Object.keys(errs).length === 0) submitToServer(form);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email}
onChange={handleChange} onBlur={handleBlur} />
{errors.email && <p>{errors.email}</p>}
<input name="password" type="password" value={form.password}
onChange={handleChange} onBlur={handleBlur} />
{errors.password && <p>{errors.password}</p>}
<button type="submit">Sign Up</button>
</form>
);
}
Touched fields pattern
A more granular approach: track which fields the user has interacted with ("touched"). Show validation errors only for touched fields.
function ToucledForm() {
const [form, setForm] = useState({ email: '', age: '' });
const [touched, setTouched] = useState({});
const [errors, setErrors] = useState({});
const validate = (values) => {
const errs = {};
if (!values.email.includes('@')) errs.email = 'Invalid email.';
if (isNaN(values.age) || values.age < 18) errs.age = 'Must be 18+.';
return errs;
};
const handleChange = (e) => {
const updated = { ...form, [e.target.name]: e.target.value };
setForm(updated);
setErrors(validate(updated));
};
const handleBlur = (e) => {
setTouched(prev => ({ ...prev, [e.target.name]: true }));
};
// Only show error if field has been touched
const showError = (field) => touched[field] && errors[field];
return (
<form>
<input name="email" value={form.email}
onChange={handleChange} onBlur={handleBlur} />
{showError('email') && <p>{errors.email}</p>}
<input name="age" value={form.age}
onChange={handleChange} onBlur={handleBlur} />
{showError('age') && <p>{errors.age}</p>}
</form>
);
}
Schema validation with Zod
For production forms, schema validation libraries are worth using. Zod is the most popular in the React ecosystem. It defines validation rules as a typed schema and produces structured error objects.
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Minimum 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
age: z.number().min(18, 'Must be at least 18'),
});
// Validate in a handler
const handleSubmit = (e) => {
e.preventDefault();
const result = schema.safeParse(form);
if (!result.success) {
const formatted = result.error.format();
setErrors({
email: formatted.email?._errors[0],
password: formatted.password?._errors[0],
age: formatted.age?._errors[0],
});
return;
}
submitToServer(result.data);
};
2.4 Custom Form Handling
useForm hook — full implementation
function useForm({ initialValues, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;
const updated = { ...values, [name]: newValue };
setValues(updated);
if (touched[name] && validate) {
setErrors(prev => ({ ...prev, ...validate(updated) }));
}
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) {
const errs = validate(values);
setErrors(prev => ({ ...prev, [name]: errs[name] }));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
const allTouched = Object.keys(initialValues).reduce(
(acc, k) => ({ ...acc, [k]: true }), {}
);
setTouched(allTouched);
const errs = validate ? validate(values) : {};
setErrors(errs);
if (Object.keys(errs).length > 0) return;
setSubmitting(true);
setSubmitError(null);
try {
await onSubmit(values);
} catch (err) {
setSubmitError(err.message);
} finally {
setSubmitting(false);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
setSubmitting(false);
setSubmitError(null);
};
const getFieldProps = (name) => ({
name,
value: values[name],
onChange: handleChange,
onBlur: handleBlur,
});
return {
values, errors, touched, submitting, submitError,
handleChange, handleBlur, handleSubmit, reset, getFieldProps,
};
}
// ── Usage ──────────────────────────────────────────────────────
function ContactForm() {
const form = useForm({
initialValues: { name: '', email: '', message: '' },
validate: (v) => {
const e = {};
if (!v.name) e.name = 'Name is required.';
if (!/\S+@\S+/.test(v.email)) e.email = 'Invalid email.';
if (v.message.length < 10) e.message = 'Too short.';
return e;
},
onSubmit: async (values) => {
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(values),
headers: { 'Content-Type': 'application/json' },
});
},
});
const showError = (name) =>
form.touched[name] && form.errors[name]
? <p style={{color:'red'}}>{form.errors[name]}</p>
: null;
return (
<form onSubmit={form.handleSubmit}>
<input {...form.getFieldProps('name')} placeholder="Name" />
{showError('name')}
<input {...form.getFieldProps('email')} placeholder="Email" type="email" />
{showError('email')}
<textarea {...form.getFieldProps('message')} placeholder="Message" />
{showError('message')}
{form.submitError && <p style={{color:'red'}}>{form.submitError}</p>}
<button type="submit" disabled={form.submitting}>
{form.submitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}
Dynamic fields
Some forms require fields that can be added or removed — like adding multiple phone numbers or line items in an invoice.
function PhoneNumbers() {
const [phones, setPhones] = useState(['']);
const add = () => setPhones(prev => [...prev, '']);
const remove = (index) =>
setPhones(prev => prev.filter((_, i) => i !== index));
const update = (index, value) =>
setPhones(prev => prev.map((p, i) => i === index ? value : p));
return (
<div>
{phones.map((phone, index) => (
<div key={index}>
<input
value={phone}
onChange={e => update(index, e.target.value)}
placeholder={`Phone ${index + 1}`}
/>
{phones.length > 1 && (
<button type="button" onClick={() => remove(index)}>Remove</button>
)}
</div>
))}
<button type="button" onClick={add}>Add phone</button>
</div>
);
}
2.5 Async Submission
Production form submissions involve network requests. Handle the three states: submitting (loading), success, and error.
function RegisterForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [status, setStatus] = useState('idle'); // 'idle' | 'submitting' | 'success' | 'error'
const [serverError, setServerError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('submitting');
setServerError('');
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) {
const { message } = await res.json();
throw new Error(message || 'Registration failed');
}
setStatus('success');
} catch (err) {
setStatus('error');
setServerError(err.message);
}
};
if (status === 'success') {
return <p>Registration successful! Check your email.</p>;
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email}
onChange={e => setForm(p => ({...p, email: e.target.value}))} />
<input name="password" type="password" value={form.password}
onChange={e => setForm(p => ({...p, password: e.target.value}))} />
{status === 'error' && <p style={{color:'red'}}>{serverError}</p>}
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Registering...' : 'Register'}
</button>
</form>
);
}
2.6 Form Libraries Overview
| Library | Approach | Best for | Key benefit |
|---|---|---|---|
| React Hook Form | Uncontrolled internally, minimal re-renders | Performance-sensitive, large forms | Very few re-renders; small bundle |
| Formik | Controlled, renders on every change | Forms with complex logic, legacy codebases | Mature, well-documented, large ecosystem |
| TanStack Form | Type-safe first, framework-agnostic | TypeScript-heavy projects | Best TypeScript inference |
| Custom hook | Whatever you build | Simple forms, full control | No dependency, full understanding |
Q & A — Forms
action URL, which reloads the page and destroys all React state. e.preventDefault() stops that so we can handle submission in JavaScript. Always call it first in the submit handler, before any async logic.value on an input, React takes over rendering that input. It always renders whatever is in value. If you don't provide an onChange handler, the displayed value can never change because React always overwrites it with the same state value. React will warn about this in the console: "You provided a value prop without an onChange handler." Either add an onChange or use defaultValue for an uncontrolled input.touched state object that tracks which fields the user has blurred (moved focus away from). In the onBlur handler, mark the field as touched. Only render error messages for fields that are in the touched set. This prevents the jarring experience of seeing "required" errors before the user has even tried to fill the form.handleChange handler — that keeps most field handling to one function. Second, split the form into multiple logical steps (multi-step form) if possible. Third, extract the form state and validation into a custom hook. For truly large forms, React Hook Form is specifically designed for this and handles performance well with uncontrolled inputs.const fileRef = useRef(null);
const handleSubmit = () => {
const file = fileRef.current.files[0];
const formData = new FormData();
formData.append('avatar', file);
fetch('/api/upload', { method: 'POST', body: formData });
};
return <input type="file" ref={fileRef} />;
For controlled-style UI (preview, clear button), maintain separate state for the file name/preview URL, but use a ref for the actual file input.value makes the input controlled — React manages the displayed value. defaultValue sets the initial value of an uncontrolled input — the DOM manages it after that. Using value without onChange creates a read-only input. Using defaultValue allows the DOM to change freely. Never use both on the same input.initialValues: setForm(initialValues). Also reset the errors, touched, and submitted state. If you built a custom hook like useForm, expose a reset() function that restores all state to initial. With React Hook Form, call reset() from the returned methods.Component Lifecycle
Every React component goes through a predictable lifecycle: mount (created and inserted into the DOM), update (re-rendered due to state or prop changes), and unmount (removed from the DOM). Understanding this lifecycle precisely is essential for writing effects, subscriptions, and animations that behave correctly.
3.1 Lifecycle Methods in Class Components
Class components expose lifecycle as named methods on the class. You override the ones you need. Knowing these is important because: (a) legacy codebases use them extensively, and (b) each maps directly to a useEffect pattern, making the relationship between old and new APIs concrete.
Mount phase methods
class DataComponent extends React.Component {
constructor(props) {
// Called first. Set initial state. Do not call fetch here.
super(props);
this.state = {
data: null,
loading: false,
error: null,
};
}
static getDerivedStateFromProps(props, state) {
// Called before every render (mount and update).
// Returns an object to merge into state, or null.
// Rarely needed — exists for cases where state derives from props.
return null;
}
componentDidMount() {
// Called once, after the component's output is rendered and committed to DOM.
// The DOM is ready here. Safe to read layout, start subscriptions, fetch data.
this.setState({ loading: true });
fetch(`/api/data/${this.props.id}`)
.then(res => res.json())
.then(data => this.setState({ data, loading: false }))
.catch(err => this.setState({ error: err.message, loading: false }));
}
render() {
// Must be a pure function of this.props and this.state.
// No side effects here. Return JSX (or null to render nothing).
const { data, loading, error } = this.state;
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{data?.name}</div>;
}
}
Update phase methods
class TrackingComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
// Called before re-render when props or state change.
// Return false to skip the re-render.
// Equivalent to React.memo for class components.
return nextProps.id !== this.props.id || nextState.count !== this.state.count;
}
getSnapshotBeforeUpdate(prevProps, prevState) {
// Called right before the DOM is updated.
// The return value is passed as the third argument to componentDidUpdate.
// Use case: capture scroll position before a DOM update.
const list = this.listRef.current;
return list ? list.scrollHeight - list.scrollTop : null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// Called after every re-render. First render doesn't trigger this.
// Always compare prevProps/prevState before doing work, or you'll infinite loop.
if (prevProps.id !== this.props.id) {
// id changed — fetch new data
this.fetchData(this.props.id);
}
if (snapshot !== null) {
// Restore scroll position using the snapshot
const list = this.listRef.current;
list.scrollTop = list.scrollHeight - snapshot;
}
}
componentWillUnmount() {
// Called just before the component is removed from the DOM.
// Cancel all subscriptions, timers, pending requests.
clearInterval(this.intervalId);
this.subscription.unsubscribe();
}
}
Error boundaries
Error boundaries are class components that catch JavaScript errors in their child tree. There is no hook equivalent — if you need error boundaries in a functional component app, you still write a class component for this one purpose.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Update state to show fallback UI
return { hasError: true, error };
}
componentDidCatch(error, info) {
// Log error to an error reporting service
console.error('Caught error:', error, info.componentStack);
logErrorToService(error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
// Usage — wrap any subtree that might throw
<ErrorBoundary fallback={<p>Failed to load component.</p>}>
<RiskyComponent />
</ErrorBoundary>
use API and improved error handling. The react-error-boundary npm package provides a hook-friendly wrapper around class error boundaries and is widely used in production apps.
3.2 Component Mounting, Updating, Unmounting
Mounting
Mounting is when React creates a component instance and inserts its output into the real DOM for the first time. React calls the component function, produces JSX output, calculates what DOM nodes to create, creates them, and inserts them. After the DOM is updated, useEffect callbacks run (after the browser paints).
Updating
A component re-renders when:
- Its own state changes (via a state setter)
- Its parent re-renders (even if no props changed)
- Its props change
- A context it consumes changes
Re-rendering is not the same as DOM update. React renders the component function to get new output, diffs it against the previous output (reconciliation), and only updates the DOM nodes that actually changed. Rendering can be fast even when DOM updates are sparse.
Unmounting
Unmounting happens when a component is removed from the tree — typically because a condition became false, a route changed, or the parent removed it from a list. React removes the DOM nodes and calls all cleanup functions from active effects.
Strict Mode behaviour
In React 18 with Strict Mode, React mounts → unmounts → remounts every component in development. This is intentional: it surfaces effects that don't clean up properly. Your cleanup functions must completely undo what setup did, so the component behaves identically on the second mount.
// This is fine — cleanup fully reverses setup
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // complete reversal
}, []);
// This breaks on second mount — the counter is incremented twice
useEffect(() => {
fetch('/api/increment-view-count'); // no way to "undo" this request
// no cleanup
}, []);
State batching
React 18 introduced automatic batching: multiple state updates in the same event handler, timeout, or promise callback are batched into a single re-render. Before React 18, only updates inside event handlers were batched.
// React 18: all three updates batched → one re-render
const handleClick = () => {
setCount(c => c + 1);
setFlag(f => !f);
setName('Alice');
// only one re-render
};
// React 18: batched even in async code
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// one re-render (was two re-renders in React 17)
}, 1000);
// To opt OUT of batching (rare):
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // renders immediately
flushSync(() => setFlag(f => !f)); // renders immediately
3.3 Lifecycle with useEffect
useEffect is not a lifecycle method replacement — it is a synchronisation mechanism. The mental model is: "my component needs to stay synchronised with X. When X changes, re-run the synchronisation." The dependency array defines what X is.
Class to hooks mapping
| Class method | useEffect equivalent | Notes |
|---|---|---|
componentDidMount | useEffect(() => { ... }, []) | Runs once after first render |
componentDidUpdate | useEffect(() => { ... }, [dep]) | Runs after mount + when dep changes |
componentWillUnmount | Cleanup function returned from effect | Runs on unmount and before next effect |
componentDidMount + componentDidUpdate | useEffect(() => { ... }) — no array | Runs after every render |
shouldComponentUpdate | React.memo + custom comparator | Prevent re-renders based on prop comparison |
getDerivedStateFromProps | Compute derived values during render (no effect needed) | Prefer computing during render over syncing with effects |
Separating concerns into multiple effects
Class components forced all lifecycle logic into single methods (componentDidMount doing 5 different things). With hooks, separate each concern into its own effect. This makes them independently readable, testable, and removable.
// BAD: one effect doing multiple unrelated things
useEffect(() => {
fetchUser(userId);
document.title = `User ${userId}`;
analytics.track('page_view');
const sub = subscribe(userId);
return () => sub.unsubscribe();
}, [userId]);
// GOOD: separate concerns
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
useEffect(() => {
document.title = user ? `${user.name}` : 'Loading...';
}, [user]);
useEffect(() => {
analytics.track('page_view', { userId });
}, [userId]);
useEffect(() => {
const sub = subscribe(userId);
return () => sub.unsubscribe();
}, [userId]);
Complete lifecycle example
function ArticleViewer({ articleId, currentUser }) {
const [article, setArticle] = useState(null);
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
// Effect 1: fetch article when articleId changes
useEffect(() => {
let active = true;
setLoading(true);
setArticle(null);
fetch(`/api/articles/${articleId}`)
.then(r => r.json())
.then(data => { if (active) { setArticle(data); setLoading(false); } })
.catch(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [articleId]);
// Effect 2: real-time comments via WebSocket
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/articles/${articleId}/comments`);
ws.onmessage = (e) => setComments(JSON.parse(e.data));
return () => ws.close();
}, [articleId]);
// Effect 3: track reading time — mount only
useEffect(() => {
const start = Date.now();
return () => {
analytics.track('reading_time', {
articleId,
ms: Date.now() - start,
});
};
}, [articleId]);
// Effect 4: update document title when article loads
useEffect(() => {
if (article) document.title = article.title;
return () => { document.title = 'My App'; };
}, [article]);
if (loading) return <p>Loading...</p>;
if (!article) return <p>Article not found.</p>;
return (
<article>
<h1>{article.title}</h1>
<p>{article.body}</p>
<section>
<h2>Comments ({comments.length})</h2>
{comments.map(c => <p key={c.id}>{c.text}</p>)}
</section>
</article>
);
}
3.4 useLayoutEffect
useLayoutEffect has identical syntax to useEffect, but runs synchronously after React updates the DOM and before the browser paints. This is useful when you need to read layout information (element positions or sizes) and synchronously update the DOM to prevent a visual flicker.
| useEffect | useLayoutEffect | |
|---|---|---|
| Timing | After browser paints | After DOM update, before browser paints |
| Blocks painting? | No | Yes — can delay visual update |
| Use for | Data fetching, subscriptions, most effects | DOM measurement, tooltip positioning, scroll restoration |
| SSR | Safe | Warning on server (no DOM available) |
// Tooltip that positions itself to stay on screen
function Tooltip({ text, anchorRef }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Must run before paint to prevent flicker
const anchor = anchorRef.current.getBoundingClientRect();
const tooltip = tooltipRef.current.getBoundingClientRect();
let top = anchor.bottom + 8;
let left = anchor.left;
// Prevent going off screen
if (left + tooltip.width > window.innerWidth) {
left = window.innerWidth - tooltip.width - 8;
}
setPosition({ top, left });
}, [text]); // recalculate when text changes (tooltip might resize)
return (
<div
ref={tooltipRef}
style={{ position: 'fixed', top: position.top, left: position.left }}
>
{text}
</div>
);
}
useEffect. Only switch to useLayoutEffect when you see a visual flicker that confirms you need synchronous DOM measurement. Overusing useLayoutEffect blocks painting and makes the app feel slow.
3.5 Performance & Re-renders
Why components re-render
Understanding re-renders is fundamental to performance work. A component re-renders when:
- Its own state changes
- Its parent re-renders (most common cause of unnecessary re-renders)
- Its context value changes
- A custom hook it uses triggers a re-render
// Parent re-render causes ALL children to re-render by default
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveChild /> {/* re-renders every time count changes — even though it doesn't use count */}
<CheapChild />
</>
);
}
Preventing unnecessary re-renders
// Solution 1: React.memo — skip re-render if props are the same
const ExpensiveChild = React.memo(function ExpensiveChild() {
console.log('ExpensiveChild rendered');
return <div>I am expensive</div>;
});
// Solution 2: Move state down — only the part that uses state re-renders
function Parent() {
return (
<>
<Counter /> {/* counter state lives here — only Counter re-renders */}
<ExpensiveChild /> {/* never re-renders */}
</>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Solution 3: children prop — pass children from a parent that doesn't re-render
function ColorPicker({ children }) {
const [color, setColor] = useState('red');
return (
<div style={{ background: color }}>
<select onChange={e => setColor(e.target.value)}>
<option>red</option>
<option>blue</option>
</select>
{children} {/* children prop is stable — doesn't re-render when color changes */}
</div>
);
}
// Parent creates children — ColorPicker's state change doesn't re-render them
<ColorPicker>
<ExpensiveChild />
</ColorPicker>
React DevTools Profiler
Before optimising, measure. The React DevTools browser extension includes a Profiler tab that shows which components rendered, how long each render took, and why each component rendered (what triggered it).
- Record — click Record, interact with the app, stop recording
- Flamegraph — shows render time per component, colour-coded by duration
- Why did this render? — shows whether it was props, state, context, or parent
- Ranked chart — sorted list of components by render time
React.memo, useMemo, or useCallback preemptively. Profile first. Most React apps don't have meaningful performance problems — and premature memoization adds complexity, can introduce bugs (stale memoized values), and doesn't always help (memoization itself has a cost).
Q & A — Lifecycle
componentDidMount fires synchronously after the DOM is updated, before the browser paints (in most cases). useEffect always fires asynchronously, after the browser has painted. In practice, both are "after mount" and the difference is usually invisible. However, if you need to read layout (element size, scroll position) immediately after mount and apply a DOM change before the user sees the initial paint, use useLayoutEffect — it runs at the same time as componentDidMount.useEffect should encapsulate one concern — data fetching, event subscriptions, analytics, title updates. They run in declaration order. Separate effects have separate cleanup, so removing one effect doesn't accidentally break another's cleanup. Multiple focused effects are always preferable to one large effect doing many things.useEffect without a dependency array — the effect runs, sets state, state triggers a render, effect runs again, repeat forever; (2) putting an object or array literal directly in the dependency array — it's a new reference every render, so the effect always re-runs; (3) setting state unconditionally in componentDidUpdate without comparing previous props. Fix by adding a guard condition, using primitives in deps, or memoizing objects.useEffect runs after the browser paints — asynchronously. useLayoutEffect runs after the DOM is mutated but before the browser paints — synchronously. Use useLayoutEffect when you need to read the DOM and synchronously re-render to prevent a visible flicker (tooltip positioning, scroll restoration). For everything else — data fetching, subscriptions, logging — use useEffect. Overusing useLayoutEffect delays painting and hurts perceived performance.React.memo to skip re-renders when props are identical; (2) move state closer to where it is used so fewer components are above it; (3) use the children prop pattern to pass components from a stable parent. Use the React DevTools Profiler to see exactly what triggered the render.useEffect patterns clearer — the mapping is direct and knowing both clarifies each one. You don't need to write new class components, but you should be able to read, understand, and migrate them.flushSync from react-dom to opt out of batching for a specific update.Common Patterns
Patterns that come up repeatedly across all three days. Treat this as a quick-reference section for when trainees ask "how do I do X?"
Data fetching with loading and error state
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
fetch(url)
.then(r => r.json())
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(err => { if (!cancelled) setState({ data: null, loading: false, error: err.message }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
Debounced search input
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
const timerRef = useRef(null);
const handleChange = (e) => {
setQuery(e.target.value);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
onSearch(e.target.value);
}, 300);
};
useEffect(() => () => clearTimeout(timerRef.current), []); // cleanup on unmount
return <input value={query} onChange={handleChange} placeholder="Search..." />;
}
Toggle with useReducer
function useToggle(initial = false) {
return useReducer(state => !state, initial);
}
function Modal() {
const [isOpen, toggle] = useToggle(false);
return (
<>
<button onClick={toggle}>Open modal</button>
{isOpen && <div className="modal"><button onClick={toggle}>Close</button></div>}
</>
);
}
Infinite scroll
function useIntersectionObserver(callback, options) {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [callback, options]);
return ref;
}
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
fetchPage(page).then(newItems => setItems(prev => [...prev, ...newItems]));
}, [page]);
const sentinelRef = useIntersectionObserver(
useCallback(([entry]) => { if (entry.isIntersecting) setPage(p => p + 1); }, []),
{ threshold: 1 }
);
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
<li ref={sentinelRef} /> {/* invisible sentinel triggers load */}
</ul>
);
}
Common Mistakes
A quick-reference list of mistakes trainees make repeatedly. Use this to guide code reviews and debugging sessions.
1. Missing dependency in useEffect
// BUG: effect uses userId but doesn't declare it as a dependency
useEffect(() => {
fetch(`/api/${userId}`).then(setUser);
}, []); // stale — never re-fetches when userId changes
// FIX
}, [userId]);
2. Object/array literal in dependency array
// BUG: new object on every render → infinite loop
useEffect(() => {
fetchData(config);
}, [{ timeout: 5000 }]); // new reference each time
// FIX: move outside component or memoize
const config = useMemo(() => ({ timeout: 5000 }), []);
3. Mutating state directly
// BUG: mutates existing array — React sees same reference, no re-render
const [items, setItems] = useState([1, 2, 3]);
items.push(4);
setItems(items); // same reference — nothing updates
// FIX
setItems(prev => [...prev, 4]);
4. Async useEffect (async directly on the callback)
// BUG: async effect returns a Promise, not a cleanup function
useEffect(async () => {
const data = await fetch(url);
setData(data);
}, [url]);
// FIX
useEffect(() => {
let cancelled = false;
async function load() {
const data = await fetch(url).then(r => r.json());
if (!cancelled) setData(data);
}
load();
return () => { cancelled = true; };
}, [url]);
5. Forgetting cleanup
// BUG: event listener accumulates on every render
useEffect(() => {
window.addEventListener('resize', handleResize);
// no cleanup — adds a new listener every time deps change
});
// FIX
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
6. Reading state immediately after setting it
// BUG: setCount is asynchronous — count is still 0 here
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // logs 0, not 1
};
// FIX: read state in the next render, or use functional update
setCount(prev => {
const next = prev + 1;
console.log(next); // correct
return next;
});
7. Conditional hooks
// BUG: hook called conditionally — breaks hook order
if (isLoggedIn) {
const [profile, setProfile] = useState(null); // violates rules of hooks
}
// FIX: always call the hook, conditionally use the value
const [profile, setProfile] = useState(null);
// ...use profile only when isLoggedIn is true
Glossary
Batching
React's process of grouping multiple state updates in the same event or async call into a single re-render. React 18 introduced automatic batching for all updates, including those inside setTimeout and Promises.
Cleanup Function
A function returned from a useEffect callback. Called before the next effect run and on unmount. Used to cancel subscriptions, clear timers, abort fetches, and remove event listeners.
Controlled Component
A form input whose displayed value is driven by React state. Every change updates state. React is the single source of truth, not the DOM.
Custom Hook
A JavaScript function whose name starts with use and calls other hooks. Extracts and shares stateful logic between components. Each component that calls a custom hook gets isolated state.
Dependency Array
The second argument to useEffect, useMemo, and useCallback. Declares which values the hook depends on. The hook re-runs when any dependency changes (compared with Object.is).
Fiber
React's internal data structure representing a component instance. Holds component state, effect list, and hook chain. The linked list of hooks is attached to the fiber — this is why hook order must be consistent.
Memoization
Caching the result of a computation and returning the cached value on subsequent calls when inputs haven't changed. useMemo memoizes values; useCallback memoizes function references; React.memo memoizes component output.
Mount / Unmount
Mounting: a component is inserted into the DOM for the first time. Unmounting: a component is removed from the DOM. Both trigger the appropriate useEffect runs and cleanups.
Reconciliation
React's process of comparing new render output with the previous output and computing the minimal set of DOM changes needed. Re-rendering does not always mean DOM changes.
Ref
A mutable container ({ current: value }) returned by useRef. Persists across renders without causing re-renders when mutated. Used for DOM node references, timer IDs, cancellation flags, and previous value storage.
Side Effect
Any operation outside the React render cycle: network requests, DOM mutations, timers, subscriptions, logging. Managed via useEffect.
Stale Closure
When a function (inside an effect or event handler) captures a variable from a previous render and uses the old value rather than the current one. Fixed with functional state updates, correct dependency arrays, or refs.
Uncontrolled Component
A form input managed by the DOM. React reads its value via a ref on demand (e.g., on submit), rather than on every change.
Error Boundary
A class component that catches JavaScript errors in its child tree and renders a fallback UI instead of crashing. Must be a class component (no hook equivalent as of React 18).
To add a new topic: add a new <div class="chapter-label"> + <h1 id="..."> block below, and add the corresponding sidebar links under a new <div class="sec"> label. The structure extends naturally.