React Core β Topic Progress
0 / 7 read
Fundamentals
What is React & How it WorksCritical
βΌ
Definition: React is a JavaScript library for building user interfaces using a component-based architecture. It maintains a Virtual DOM β a lightweight in-memory copy of the real DOM β and uses a diffing algorithm (Reconciliation) to calculate the minimal set of changes needed, then applies only those to the real DOM.
React's core philosophy: UI is a pure function of state β
UI = f(state). When state changes, React re-runs the function and efficiently updates the DOM.
Key concepts:
- Virtual DOM β React creates a VDOM tree, diffs it with the previous tree, and patches only changed nodes in the real DOM
- Reconciliation β The diffing algorithm. React assumes components of different types produce different trees (tears down + rebuilds), and uses the
keyprop to identify list items - Fiber β React 16+ internal architecture. Breaks rendering into small units of work, enabling pausing, prioritising, and resuming work (concurrent rendering)
- Unidirectional data flow β Data flows down (parent β child via props), events flow up (child β parent via callbacks)
How JSX Compiles
// JSX you write:
const element = <h1 className="title">Hello, {name}</h1>;
// Babel compiles it to:
const element = React.createElement(
'h1',
{ className: 'title' },
'Hello, ',
name
);
// React.createElement returns a plain JS object (VDOM node):
// {
// type: 'h1',
// props: { className: 'title', children: ['Hello, ', name] }
// }
Code Walkthrough
- JSX is syntactic sugar β it's not HTML, it compiles to React.createElement() calls
- React.createElement returns a plain JavaScript object describing the desired UI
- React uses these objects to build the Virtual DOM tree
- On re-render, React diffs the new VDOM tree against the old one and updates only what changed
JSX Rules & ExpressionsCritical
βΌ
Definition: JSX (JavaScript XML) is a syntax extension that lets you write HTML-like markup inside JavaScript. It must follow specific rules because it compiles to function calls, not real HTML.
JSX Rules:
- Return a single root element β wrap multiple elements in a
<div>or<></>(Fragment) - All tags must be closed β self-closing or explicit closing tag (
<img />) - Use camelCase for attributes β
classNamenotclass,onClicknotonclick,htmlFornotfor - JavaScript expressions go inside curly braces
{} - Comments in JSX:
{/* comment */} null,undefined,falserender nothing β useful for conditional rendering
JSX Patterns
function UserCard({ user, isAdmin }) {
const fullName = `${user.firstName} ${user.lastName}`;
return (
<> {/* Fragment β no extra DOM node */}
<h2 className="card-title">{fullName.toUpperCase()}</h2>
{/* Conditional rendering */}
{isAdmin && <span className="badge">Admin</span>}
{/* Ternary */}
<p>{user.active ? "Active" : "Inactive"}</p>
{/* Dynamic styles */}
<div style={{ color: user.active ? "green" : "red" }}>
Status
</div>
{/* Lists */}
<ul>
{user.roles.map(role => (
<li key={role.id}>{role.name}</li>
))}
</ul>
{/* Self-closing */}
<img src={user.avatar} alt={fullName} />
</>
);
}
Code Walkthrough
- <> </> is shorthand for React.Fragment β avoids an unnecessary DOM wrapper div
- && renders the right side only if the left side is truthy β careful: 0 && x renders '0', not nothing
- Inline styles take a JavaScript object with camelCase properties
- Always use key prop on list items β use stable IDs, not array index when items can reorder
- Self-closing tags like <img /> and <input /> are required in JSX
Components β Function vs ClassCritical
βΌ
Definition: A React component is a reusable, self-contained piece of UI. Function components are plain JavaScript functions that accept props and return JSX. Class components extend React.Component and use lifecycle methods. Function components with hooks are the modern standard.
Function components (preferred):
- Plain JS function, receives props, returns JSX
- Use hooks for state and side effects
- Simpler, less boilerplate, easier to test
- Compiler optimizations (React Compiler / Forget)
- Extend React.Component
- State in
this.state, updates viathis.setState() - Lifecycle:
componentDidMount,componentDidUpdate,componentWillUnmount - Required for Error Boundaries (still no hook equivalent)
Function Component (Modern)
// Modern function component
function UserProfile({ userId, onFollow }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return (
<div className="profile">
<h1>{user.name}</h1>
<button onClick={() => onFollow(user.id)}>Follow</button>
</div>
);
}
Class Component (Legacy)
// Legacy class component β same functionality
class UserProfile extends React.Component {
state = { user: null, loading: true };
componentDidMount() {
fetchUser(this.props.userId).then(data => {
this.setState({ user: data, loading: false });
});
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
fetchUser(this.props.userId).then(data => {
this.setState({ user: data, loading: false });
});
}
}
render() {
const { user, loading } = this.state;
if (loading) return <Spinner />;
return (
<div className="profile">
<h1>{user.name}</h1>
<button onClick={() => this.props.onFollow(user.id)}>Follow</button>
</div>
);
}
}
Code Walkthrough
- Function components use hooks (useState, useEffect) instead of lifecycle methods
- Class components require this.setState() β never mutate this.state directly
- componentDidUpdate needs manual comparison of previous vs current props to avoid infinite loops
- For new code, always write function components β they're shorter and easier to reason about
Error Boundaries are the only remaining use case for class components β there's no hook equivalent for componentDidCatch yet.
Props β Passing & Receiving DataCritical
βΌ
Definition: Props (properties) are the mechanism for passing data from a parent component to a child. Props are read-only β a child must never modify its props. They flow in one direction: parent β child.
Props can be anything: strings, numbers, booleans, objects, arrays, functions, even other React components.
Key patterns:
- Destructuring β extract props in the function signature for cleaner code
- Default props β set defaults with default parameter values
- Spread props β pass all props to a child with
{...props} - children prop β everything between opening and closing tags
- Render props β passing a function as a prop to control rendering
Props Patterns
// 1. Destructuring with defaults
function Button({ label, variant = 'primary', onClick, disabled = false }) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
// 2. children prop
function Card({ title, children, className = '' }) {
return (
<div className={`card ${className}`}>
<h2 className="card-title">{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage:
<Card title="Welcome">
<p>This is the children content</p>
<Button label="Click me" onClick={handleClick} />
</Card>
// 3. Spread props (forwarding to DOM element)
function Input({ label, error, ...inputProps }) {
return (
<div>
<label>{label}</label>
<input {...inputProps} /> {/* spreads all remaining props */}
{error && <span className="error">{error}</span>}
</div>
);
}
// 4. Function as prop (callback)
function List({ items, renderItem }) {
return <ul>{items.map(renderItem)}</ul>;
}
// Usage:
<List
items={users}
renderItem={user => <li key={user.id}>{user.name}</li>}
/>
Code Walkthrough
- Destructuring in the parameter list is cleaner than accessing props.x everywhere
- Default parameter values replace the old defaultProps pattern
- The ...rest spread collects all unspecified props β useful for wrapping native elements
- children is a special prop β anything between JSX opening and closing tags
- Never modify props β they belong to the parent. Use local state for derived values
State & Events
State β Understanding ImmutabilityCritical
βΌ
Definition: State is private data owned by a component that can change over time, triggering re-renders. Unlike props, state is managed inside the component. The critical rule: never mutate state directly β always create a new value.
Why immutability matters:
- React detects changes by reference comparison (
Object.is()). Mutating the existing object doesn't change its reference β React won't re-render - Immutable updates make it easy to implement undo/redo, debugging, time-travel
- Pure function components are easier to test and predict
- Objects β spread:
{ ...obj, key: newValue } - Arrays add β spread:
[...arr, newItem] - Arrays remove β filter:
arr.filter(x => x.id !== id) - Arrays update β map:
arr.map(x => x.id === id ? {...x, ...changes} : x) - Nested objects β deep spread or use Immer library
Immutable State Updates
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false },
{ id: 2, text: 'Build something', done: false },
]);
// β WRONG β mutating state, React won't re-render
const toggleWrong = (id) => {
const todo = todos.find(t => t.id === id);
todo.done = !todo.done; // mutation!
setTodos(todos); // same reference, React ignores it
};
// β
CORRECT β new array, new objects
const toggleTodo = (id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
};
const addTodo = (text) => {
setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
};
const deleteTodo = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// Nested object update
const [user, setUser] = useState({ name: 'Alice', address: { city: 'NYC' } });
const updateCity = (city) => {
setUser(prev => ({
...prev,
address: { ...prev.address, city } // spread nested object too
}));
};
}
Code Walkthrough
- Always use the functional form prev => newValue when new state depends on previous state β avoids stale closure bugs
- arr.map() returns a new array with updated items β the original array is untouched
- arr.filter() returns a new array with items removed
- Deep cloning every level of nesting with spread can get verbose β consider Immer for complex nested state
- Date.now() or crypto.randomUUID() for IDs β never use array index as a key
If you have deeply nested state that's complex to update immutably, consider splitting it into multiple useState calls or using useReducer with Immer.
Event HandlingCritical
βΌ
Definition: React uses Synthetic Events β cross-browser wrappers around native browser events that normalize inconsistencies. Event handlers are camelCase props that take functions, not strings.
Key differences from HTML events:
- camelCase:
onClicknotonclick,onChangenotonchange - Pass a function reference, not a string:
onClick={handleClick}notonClick="handleClick()" e.preventDefault()works the same as native- Events are delegated to the root β React attaches one listener at the top
Event Handling Patterns
function EventExamples() {
const [value, setValue] = useState('');
// 1. Basic click handler
const handleClick = () => console.log('clicked');
// 2. Click with event object
const handleRightClick = (e) => {
e.preventDefault(); // prevent context menu
console.log('right clicked at', e.clientX, e.clientY);
};
// 3. Input onChange β always use e.target.value
const handleChange = (e) => setValue(e.target.value);
// 4. Pass extra data to handler
const handleItemClick = (id) => (e) => {
e.stopPropagation(); // stop event bubbling
console.log('item clicked:', id);
};
// 5. Form submit
const handleSubmit = (e) => {
e.preventDefault(); // prevent page reload
console.log('submitted:', value);
};
// 6. Keyboard events
const handleKeyDown = (e) => {
if (e.key === 'Enter') handleSubmit(e);
if (e.key === 'Escape') setValue('');
if (e.ctrlKey && e.key === 's') { e.preventDefault(); save(); }
};
return (
<form onSubmit={handleSubmit}>
<input
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<button type="button" onClick={handleClick}>Click</button>
<button onContextMenu={handleRightClick}>Right Click Me</button>
{items.map(item => (
<div key={item.id} onClick={handleItemClick(item.id)}>
{item.name}
</div>
))}
</form>
);
}
Code Walkthrough
- onClick={handleClick} β pass the function itself, not onClick={handleClick()} which calls it on render
- handleItemClick(id) returns a new function β this is the currying pattern for passing data
- e.stopPropagation() stops the event from bubbling up to parent elements
- e.preventDefault() prevents default browser behaviour (form submit, link navigation, context menu)
- For keyboard events, always check e.key (modern) not e.keyCode (deprecated)
Controlled vs Uncontrolled FormsImportant
βΌ
Definition: In a controlled component, form data is driven by React state β the input's value is always the state value. In an uncontrolled component, the DOM itself manages the data, accessed via a ref. Controlled is the React-recommended approach for most cases.
Controlled inputs:
- value prop + onChange handler β React is the single source of truth
- Enables instant validation, conditional disabling, formatting on type
- Slightly more code but full control
- Use
useRef+ref.current.valueto read value on submit - Less re-renders β DOM manages state internally
- Good for: file inputs (always uncontrolled), simple one-off forms, integrating with non-React libraries
Controlled Form
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// Clear error on change
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
};
const validate = () => {
const errs = {};
if (!form.email.includes('@')) errs.email = 'Invalid email';
if (form.password.length < 6) errs.password = 'Too short';
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
submitLogin(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
type="email"
value={form.email} // controlled
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
<input
name="password"
type="password"
value={form.password} // controlled
onChange={handleChange}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Login</button>
</form>
);
}
Code Walkthrough
- [name]: value uses computed property name β one handler for all fields
- value={form.email} makes it controlled β React owns the value, DOM reflects it
- Validation can run on every keystroke (onChange) or only on submit
- For complex forms consider react-hook-form β minimal re-renders, schema validation with Zod
React Hooks β Topic Progress
0 / 7 read
Core Hooks
useState β Complete GuideCritical
βΌ
Definition:
useState returns a state variable and a setter function. React re-renders the component whenever the state changes. State is preserved between renders and is isolated per component instance.Key rules:
- The setter always triggers a re-render (even if value is the same β React 18 may batch and skip)
- Use the functional form
setState(prev => newVal)when new state depends on previous β avoids stale closure bugs - State updates are batched in React 18 β multiple setStates in one event handler = one re-render
- For complex/related state,
useReduceris often cleaner - Initial state can be a lazy initializer function to avoid expensive computation on every render
useState Patterns
// 1. Primitive state
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [isOpen, setIsOpen] = useState(false);
// 2. Functional update β safe when depending on previous state
const increment = () => setCount(prev => prev + 1);
// PROBLEM with non-functional:
const badDouble = () => {
setCount(count + 1); // reads stale 'count' from closure
setCount(count + 1); // same stale value β only increments by 1!
};
// SOLUTION with functional form:
const goodDouble = () => {
setCount(prev => prev + 1); // queues: count + 1
setCount(prev => prev + 1); // queues: (count + 1) + 1 = +2 β
};
// 3. Object state β spread to update
const [user, setUser] = useState({ name: '', age: 0, email: '' });
const updateName = (name) => setUser(prev => ({ ...prev, name }));
// 4. Lazy initialization β function only runs on first render
const [data, setData] = useState(() => {
const saved = localStorage.getItem('data'); // expensive on every render
return saved ? JSON.parse(saved) : [];
});
// 5. Toggling booleans
const toggle = () => setIsOpen(prev => !prev);
// 6. Reset to initial
const INITIAL = { name: '', email: '' };
const [form, setForm] = useState(INITIAL);
const reset = () => setForm(INITIAL);
Code Walkthrough
- Functional form prev => newVal is always safe for derived updates β avoids race conditions in async code
- Stale closure: if count=0 and you call setCount(count+1) twice, both closures capture count=0 β result is 1 not 2
- Lazy initializer is called once on mount β use it for reading localStorage, complex initial computation
- React 18 automatic batching: even async setState calls (in setTimeout, fetch callbacks) are batched into one render
- Resetting: keeping INITIAL outside the component means the reference never changes β safe to use as a default
useEffect β Mastering Side EffectsCritical
βΌ
Definition:
useEffect runs after the render is painted to screen. It's for synchronising React with external systems: APIs, subscriptions, timers, DOM manipulation, third-party libraries. It runs after every render by default.Dependency array controls when the effect runs:
- No array β runs after every render
- Empty array
[]β runs once after mount [dep1, dep2]β runs when any dep changes
- Missing dependencies in the array (stale closure)
- Async function directly inside useEffect (return must be cleanup, not a Promise)
- Object/function in deps causing infinite loops
useEffect Patterns
// 1. Data fetching with cleanup
useEffect(() => {
let cancelled = false;
async function loadUser() {
setLoading(true);
try {
const data = await fetchUser(userId);
if (!cancelled) setUser(data); // guard against stale response
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
loadUser();
return () => { cancelled = true; }; // cleanup cancels stale responses
}, [userId]); // re-runs when userId changes
// 2. Event listener with cleanup
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // cleanup
}, []); // only attach once
// 3. Timer
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id); // cleanup on unmount
}, []);
// 4. Syncing with external library
useEffect(() => {
const chart = new Chart(canvasRef.current, options);
return () => chart.destroy(); // cleanup
}, [options]);
// 5. WRONG β async directly in useEffect
useEffect(async () => { // β the cleanup return would be a Promise
const data = await fetch('/api');
setData(data);
}, []);
// CORRECT β define async function inside
useEffect(() => {
(async () => {
const data = await fetch('/api').then(r => r.json());
setData(data);
})();
}, []);
Code Walkthrough
- The cancelled flag prevents setting state after the component unmounts or after deps changed β prevents memory leaks
- Always return a cleanup function for subscriptions, timers, and event listeners
- Async/await in useEffect: define an async function inside and call it β never make the effect callback itself async
- If an object or function is in deps and created inline, it's a new reference every render β causes infinite loop. Use useMemo/useCallback or move it outside
- useLayoutEffect runs synchronously after DOM mutations but before paint β use for measuring DOM elements
The ESLint rule react-hooks/exhaustive-deps will catch missing dependencies. Don't disable it β fix the code instead.
useContext β Shared State Without Prop DrillingImportant
βΌ
Definition:
useContext reads and subscribes to a React Context. Context lets you pass data through the component tree without manually passing props at every level. Any component that calls useContext re-renders when the context value changes.When to use Context:
- Theme (dark/light mode)
- Current authenticated user
- Locale / language
- Feature flags
Context β Provider Pattern
// 1. Create context with type safety
const ThemeContext = createContext(null);
// 2. Provider component
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const toggle = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
}, []);
// Memoize value to prevent unnecessary re-renders
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// 3. Custom hook β encapsulates usage, validates context is available
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
// 4. Consumer component
function Header() {
const { theme, toggle } = useTheme();
return (
<header data-theme={theme}>
<button onClick={toggle}>
{theme === 'dark' ? 'βοΈ Light' : 'π Dark'}
</button>
</header>
);
}
// 5. Optimized: split contexts for independent re-renders
const UserDataContext = createContext(null); // re-renders on user change
const UserActionsContext = createContext(null); // stable β never re-renders
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const actions = useMemo(() => ({
login: (u) => setUser(u),
logout: () => setUser(null),
}), []);
return (
<UserActionsContext.Provider value={actions}>
<UserDataContext.Provider value={user}>
{children}
</UserDataContext.Provider>
</UserActionsContext.Provider>
);
}
Code Walkthrough
- createContext(null) β null as default means the provider is required, and the custom hook can throw a helpful error
- Wrapping the context value in useMemo prevents a new object reference every render β without it, all consumers re-render on every parent re-render
- Custom hook pattern (useTheme) is cleaner than calling useContext directly everywhere β single place to add validation
- Split contexts: components that only dispatch actions get a stable context reference and never re-render when data changes
- Don't put rapidly-changing data (mouse position, scroll) in Context β it'll cause widespread re-renders
useReducer β Complex State LogicImportant
βΌ
Definition:
useReducer is an alternative to useState for complex state logic involving multiple sub-values or when next state depends on previous in non-trivial ways. It extracts state logic from the component into a pure reducer function.When useReducer beats useState:
- Multiple related state values that update together
- Next state depends on previous in complex ways
- State transitions have explicit names (actions) β self-documenting
- Testing: reducer is a pure function β easy to unit test
- Can share dispatch via context (Redux-like pattern)
(state, action) => newState β must be pure (no side effects, no mutations)useReducer β Shopping Cart
// 1. Define action types (TypeScript-style discriminated union)
const initialState = {
items: [],
total: 0,
loading: false,
error: null,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
const items = existing
? state.items.map(i =>
i.id === action.payload.id
? { ...i, qty: i.qty + 1 }
: i
)
: [...state.items, { ...action.payload, qty: 1 }];
return {
...state,
items,
total: items.reduce((sum, i) => sum + i.price * i.qty, 0),
};
}
case 'REMOVE_ITEM':
const items = state.items.filter(i => i.id !== action.payload);
return { ...state, items, total: items.reduce((s,i) => s+i.price*i.qty, 0) };
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'CLEAR':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });
return (
<div>
{state.items.map(item => (
<CartItem key={item.id} item={item} onRemove={removeItem} />
))}
<p>Total: ${state.total.toFixed(2)}</p>
<button onClick={() => dispatch({ type: 'CLEAR' })}>Clear Cart</button>
</div>
);
}
Code Walkthrough
- Reducer is a pure function β same inputs always produce same output. No API calls, no mutations inside
- dispatch({ type, payload }) is the standardised action format β type is required, payload is convention
- throw new Error in default case catches typos in action types early
- The reducer can be tested completely independently of any component β just call cartReducer(state, action)
- For even cleaner code with complex nested updates, use Immer's produce() inside the reducer
Use useReducer over useState when: 3+ related state values, complex update logic, or when you want to test state transitions independently.
useRef β DOM Access & Mutable ValuesImportant
βΌ
Definition:
useRef returns a mutable ref object whose .current property is initialised with the passed argument. The key difference from state: updating a ref does NOT trigger a re-render. Used for: DOM element access, storing mutable values that shouldn't trigger re-renders.Two distinct use cases:
- DOM refs β pass as the
refattribute to access the DOM node directly (focus, scroll, measure, integrate with non-React libraries) - Instance variables β store mutable values that should persist across renders but shouldn't trigger re-renders (timer IDs, previous values, flags)
- useState: changing value re-renders the component, value is reflected in JSX
- useRef: changing .current does NOT re-render, value is NOT reflected in JSX automatically
useRef Patterns
// 1. DOM ref β focus an input
function SearchInput({ autoFocus }) {
const inputRef = useRef(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const handleClear = () => {
inputRef.current.value = ''; // for uncontrolled inputs
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="search" placeholder="Search..." />
<button onClick={handleClear}>Clear</button>
</div>
);
}
// 2. Store previous value
function usePrevoius(value) {
const prevRef = useRef(value);
useEffect(() => {
prevRef.current = value; // update after render
});
return prevRef.current; // returns value from previous render
}
// 3. Store timer ID (doesn't need to trigger re-render)
function AutoSave({ content }) {
const timerRef = useRef(null);
useEffect(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => save(content), 1000);
return () => clearTimeout(timerRef.current);
}, [content]);
}
// 4. Track mounted state (prevent setState after unmount)
function DataFetcher({ url }) {
const isMountedRef = useRef(true);
const [data, setData] = useState(null);
useEffect(() => {
isMountedRef.current = true;
fetch(url).then(r => r.json()).then(d => {
if (isMountedRef.current) setData(d);
});
return () => { isMountedRef.current = false; };
}, [url]);
}
// 5. Scroll to bottom (chat UI)
function Chat({ messages }) {
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div>
{messages.map(m => <Message key={m.id} message={m} />)}
<div ref={bottomRef} />
</div>
);
}
Code Walkthrough
- ref.current?.focus() β optional chaining because current might be null before mount
- Storing timerRef prevents a stale closure β if stored in state, it would trigger re-renders unnecessarily
- The isMounted pattern is an older approach β in React 18 with useEffect cleanup, the cancelled flag in a closure is cleaner
- scrollIntoView on the bottom ref is the simplest way to auto-scroll a chat window
- TypeScript: useRef<HTMLInputElement>(null) β type the expected DOM element
useMemo & useCallback β MemoizationImportant
βΌ
Definition:
useMemo memoizes a computed value, recomputing only when dependencies change. useCallback memoizes a function reference, returning the same function instance between renders. Both exist purely for performance optimization.When to use them:
useMemoβ expensive computation (sorting/filtering large arrays, complex derivations), value used in useEffect deps, referential equality for child component propsuseCallbackβ function passed as prop to aReact.memochild, function in useEffect dependency array
- Cheap operations β multiplication, simple concatenation β memoization overhead outweighs benefit
- When the component doesn't re-render frequently anyway
- When deps change on every render anyway (making memo useless)
useMemo & useCallback
// useMemo β expensive computation
function UserList({ users, searchTerm, sortBy }) {
// Recomputes only when users, searchTerm, or sortBy change
const processedUsers = useMemo(() => {
console.log('Filtering and sorting...'); // won't spam on every render
return users
.filter(u => u.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
}, [users, searchTerm, sortBy]);
return <ul>{processedUsers.map(u => <UserRow key={u.id} user={u} />)}</ul>;
}
// useCallback β stable function reference for React.memo child
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback: new function on every Parent re-render
// β ExpensiveChild re-renders even when count changes but text doesn't
const handleTextChange = useCallback((newText) => {
setText(newText);
}, []); // empty deps β function never changes
const handleSave = useCallback(() => {
saveToAPI(text); // text in closure
}, [text]); // re-created only when text changes
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
{/* Only re-renders when handleTextChange or handleSave changes */}
<ExpensiveChild
onTextChange={handleTextChange}
onSave={handleSave}
/>
</div>
);
}
const ExpensiveChild = React.memo(function ExpensiveChild({ onTextChange, onSave }) {
console.log('ExpensiveChild rendered'); // only when props change
return (
<div>
<input onChange={e => onTextChange(e.target.value)} />
<button onClick={onSave}>Save</button>
</div>
);
});
Code Walkthrough
- useMemo(() => compute(), [deps]) β the function runs once and its result is cached until deps change
- useCallback(fn, [deps]) is equivalent to useMemo(() => fn, [deps]) but semantically clearer for functions
- React.memo + useCallback work together β memo does shallow prop comparison, useCallback provides stable references
- Profiler first! Add memoization only after identifying a real performance problem
- In React 19 with the React Compiler, explicit useMemo/useCallback will be mostly unnecessary β compiler handles it
Rule of thumb: useMemo for values, useCallback for functions. Only add when you can measure a performance improvement with the React Profiler.
Advanced Hooks
Custom Hooks β Reusable LogicCritical
βΌ
Definition: Custom hooks are JavaScript functions starting with 'use' that can call other hooks. They let you extract stateful logic from a component into a reusable function. They don't share state between components β each call creates independent state.
Rules of Hooks (apply to custom hooks too):
- Only call hooks at the top level β never inside loops, conditions, or nested functions
- Only call hooks from React function components or other custom hooks
Essential Custom Hooks
// 1. useFetch β generic data fetching
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(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
// Usage:
const { data: user, loading, error } = useFetch(`/api/users/${id}`);
// 2. useDebounce β delay value update
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage:
const debouncedSearch = useDebounce(searchInput, 300);
useEffect(() => { fetchResults(debouncedSearch); }, [debouncedSearch]);
// 3. useLocalStorage β persist state to localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch { return initialValue; }
});
const setStoredValue = useCallback((val) => {
try {
const toStore = val instanceof Function ? val(value) : val;
setValue(toStore);
localStorage.setItem(key, JSON.stringify(toStore));
} catch (e) { console.error(e); }
}, [key, value]);
return [value, setStoredValue];
}
// 4. useMediaQuery
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage:
const isMobile = useMediaQuery('(max-width: 768px)');
Code Walkthrough
- Each call to a custom hook creates completely independent state β two components using useFetch('/api/data') each get their own fetch and state
- The cancelled flag pattern prevents state updates on unmounted components
- useLocalStorage lazy initializer only reads localStorage on first render β avoids SSR issues if wrapped in try/catch
- Custom hooks composed from other custom hooks are fine β hooks can call hooks
- Name must start with 'use' β this is what React's linter uses to enforce the rules of hooks
React Patterns β Topic Progress
0 / 6 read
Component Patterns
React.memo β Prevent Unnecessary Re-rendersImportant
βΌ
Definition:
React.memo is a Higher Order Component that memoizes a component's rendered output. It skips re-rendering if the component's props haven't changed (shallow comparison). It's the component-level equivalent of PureComponent.How it works: Before re-rendering, React shallowly compares previous and next props. If they're the same, the previous render output is reused.
When it helps:
- Component renders frequently due to parent re-renders
- Component renders are expensive (large lists, complex calculations)
- Props are primitive values or stable references (via useCallback/useMemo)
- Props include inline objects/arrays/functions β new reference every render, comparison always fails
- Component always receives different props anyway
- The component is cheap to render
React.memo Usage
// Basic React.memo
const UserCard = React.memo(function UserCard({ user, onDelete }) {
console.log('UserCard rendered:', user.name);
return (
<div className="card">
<h3>{user.name}</h3>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
);
});
function UserList({ users }) {
const [count, setCount] = useState(0);
// β New function on every render β memo is useless
const handleDelete = (id) => deleteUser(id);
// β
Stable reference β memo works correctly
const handleDelete = useCallback((id) => deleteUser(id), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{users.map(user => (
// When count changes, UserCard WON'T re-render β
<UserCard key={user.id} user={user} onDelete={handleDelete} />
))}
</div>
);
}
// Custom comparison function (use sparingly)
const Chart = React.memo(
function Chart({ data, width }) {
return <canvas>{/* expensive chart render */}</canvas>;
},
(prevProps, nextProps) => {
// Return true to SKIP re-render (props considered equal)
return prevProps.width === nextProps.width &&
prevProps.data.length === nextProps.data.length;
// Warning: this skips render even if data items changed!
}
);
Code Walkthrough
- React.memo wraps the component definition, not the JSX β memo(Component), not memo(<Component />)
- Shallow comparison: checks if prop.value === prevProp.value for each prop. Objects/arrays always fail unless same reference
- The custom comparator is dangerous β easy to create bugs where UI doesn't update. Only use when profiling shows memo is the bottleneck
- React DevTools Profiler shows grey bars for skipped renders (thanks to memo) and coloured bars for actual renders
- In React 19, the compiler handles this automatically β you won't need React.memo explicitly
Compound Components PatternAdvanced
βΌ
Definition: The Compound Components pattern allows components to share implicit state through React Context rather than explicit props. It creates an expressive, flexible API where the parent manages state and child components access it transparently.
When to use it: Building flexible UI primitives like Select, Tabs, Accordion, Modal β where the consumer wants control over the structure but not the state management.
Benefits:
- Flexible β consumer can add any markup between components
- Inversion of control β consumer decides the rendering structure
- Clean API β no complex prop drilling or render props
Compound Tabs Component
// 1. Create the context
const TabsContext = createContext(null);
// 2. Parent component manages state
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 3. Child components consume context
Tabs.List = function TabsList({ children }) {
return <div className="tabs-list" role="tablist">{children}</div>;
};
Tabs.Tab = function Tab({ value, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={activeTab === value}
className={activeTab === value ? 'tab active' : 'tab'}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
};
Tabs.Panel = function TabsPanel({ value, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
};
// 4. Usage β expressive, flexible API
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab value="profile">Profile</Tabs.Tab>
<Tabs.Tab value="settings">Settings</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel>
<Tabs.Panel value="settings"><SettingsForm /></Tabs.Panel>
<Tabs.Panel value="billing"><BillingInfo /></Tabs.Panel>
</Tabs>
Code Walkthrough
- The state lives in the parent Tabs component β child components don't need state of their own
- Context is used implicitly β consumers of Tabs don't need to pass activeTab anywhere
- Attaching sub-components as properties (Tabs.Tab) keeps the API grouped and discoverable
- This pattern is used by Headless UI, Radix UI, Reach UI β all popular accessible component libraries
- The consumer can insert arbitrary elements between Tabs.Tab components β the layout is fully flexible
Error BoundariesImportant
βΌ
Definition: Error Boundaries are class components that catch JavaScript errors anywhere in their child component tree, log them, and display a fallback UI instead of a crashed component tree. They use the
componentDidCatch and getDerivedStateFromError lifecycle methods.What they catch: Errors during rendering, in lifecycle methods, and in constructors of child components.
What they don't catch: Event handlers (use try/catch), async code (setTimeout, fetch), server-side rendering, errors thrown in the error boundary itself.
Placement strategy:
- Top-level: one boundary around the whole app (prevents total crashes)
- Feature-level: boundaries around independent features (one broken widget doesn't kill the page)
- Component-level: around individual expensive components
Error Boundary Implementation
// Error boundaries must be class components
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
// Called during render when a child throws
// Returns new state to display fallback UI
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// Called after render β good for logging
componentDidCatch(error, info) {
console.error('Caught error:', error);
console.error('Component stack:', info.componentStack);
// Send to error monitoring service
Sentry.captureException(error, { extra: info });
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="error-ui">
<h2>Something went wrong.</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage β wrap feature sections
function App() {
return (
<ErrorBoundary fallback={<AppCrashed />}>
<Header />
<ErrorBoundary fallback={<WidgetError />}>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary fallback={<SidebarError />}>
<Sidebar />
</ErrorBoundary>
</ErrorBoundary>
);
}
// react-error-boundary library (recommended)
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetch()}>
<DataComponent />
</ErrorBoundary>
Code Walkthrough
- getDerivedStateFromError is a static method β it runs during the render phase to update state for the fallback UI
- componentDidCatch runs in the commit phase β safe to perform side effects like logging here
- The 'Try again' button resets the error state β effective for transient errors
- react-error-boundary is a popular library that adds hooks (useErrorBoundary) and an onReset callback
- For async errors in event handlers: use try/catch and setState({ error: err })
Portals β Render Outside Parent DOMAdvanced
βΌ
Definition:
ReactDOM.createPortal(children, container) renders children into a different DOM node than the parent. The component is still in the React tree (events bubble normally, context works), but escapes CSS constraints like overflow:hidden or z-index stacking contexts.Classic use cases:
- Modals and dialogs β need to appear above everything
- Tooltips β need to escape overflow:hidden containers
- Dropdown menus β need to render at the body level
- Toast notifications β fixed to viewport
Modal with Portal
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
// Scroll lock
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
// Close on Escape key
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Renders to document.body β escapes any overflow:hidden parent
return createPortal(
<div
className="modal-overlay"
onClick={onClose} // click backdrop to close
>
<div
className="modal-content"
onClick={e => e.stopPropagation()} // don't close on content click
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close">β</button>
{children}
</div>
</div>,
document.body // target DOM node
);
}
// Usage
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ overflow: 'hidden' }}> {/* Portal escapes this */}
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Confirm">
<p>Are you sure?</p>
<button onClick={() => setShowModal(false)}>Confirm</button>
</Modal>
</div>
);
}
Code Walkthrough
- createPortal(jsx, domNode) β renders jsx into domNode, which can be document.body or a dedicated #modal-root element
- React events still bubble through the React component tree, not the DOM tree β onClick on the modal still reaches App's event handlers
- Context providers above the portal still work β Context.Provider wraps in React tree, not DOM tree
- Focus management: when modal opens, focus the first interactive element. When it closes, restore focus to the trigger button
- The #modal-root div in index.html is a common pattern: <div id='modal-root'></div> as a sibling of #root
forwardRef & useImperativeHandleAdvanced
βΌ
Definition:
forwardRef lets a parent component pass a ref into a child component's DOM node. useImperativeHandle lets you customise what the parent sees through the ref β exposing a controlled API instead of the raw DOM node.When you need forwardRef:
- Building reusable input/form components that need to be focused from parent
- Design system components that wrap native elements
- Animation libraries that need to imperatively trigger animations
forwardRef + useImperativeHandle
import { forwardRef, useImperativeHandle, useRef } from 'react';
// 1. forwardRef β expose the inner input's DOM node
const Input = forwardRef(function Input({ label, ...props }, ref) {
return (
<div className="input-wrapper">
<label>{label}</label>
<input ref={ref} {...props} /> {/* ref forwarded to native input */}
</div>
);
});
// Parent can now focus the input
function Form() {
const inputRef = useRef(null);
return (
<>
<Input ref={inputRef} label="Email" type="email" />
<button onClick={() => inputRef.current.focus()}>Focus Email</button>
</>
);
}
// 2. useImperativeHandle β expose a controlled API
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null);
// Only expose play, pause, seek β not the entire HTMLVideoElement
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (time) => {
if (videoRef.current) videoRef.current.currentTime = time;
},
getDuration: () => videoRef.current?.duration ?? 0,
}), []); // stable β no deps
return <video ref={videoRef} src={src} />;
});
// Parent only gets play/pause/seek β DOM node is encapsulated
function VideoPage() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="/movie.mp4" />
<button onClick={() => playerRef.current.play()}>βΆ Play</button>
<button onClick={() => playerRef.current.pause()}>βΈ Pause</button>
<button onClick={() => playerRef.current.seek(30)}>Skip 30s</button>
</>
);
}
Code Walkthrough
- forwardRef wraps the component definition: forwardRef((props, ref) => JSX) β ref is the second parameter
- The parent passes ref={videoRef} as if it were a prop β React handles forwarding it
- useImperativeHandle replaces what ref.current points to β instead of the video DOM element, it points to your custom object
- The second argument to useImperativeHandle is a factory function β return the object you want to expose
- TypeScript: useImperativeHandle<RefType>(ref, factory) β define an interface for the exposed methods
Lazy Loading & SuspenseImportant
βΌ
Definition:
React.lazy() enables code splitting β dynamically importing a component so its code is only loaded when first needed. Suspense shows a fallback while the lazy component's code is loading.Code splitting benefits:
- Reduces initial bundle size β users download only what they need
- Critical for large apps β dashboard chunks load only on /dashboard
- Works with bundlers (Webpack/Vite) that split at dynamic import() boundaries
Lazy + Suspense Patterns
import { lazy, Suspense, startTransition } from 'react';
// 1. Route-based code splitting
const Dashboard = lazy(() => import('./Dashboard'));
const Reports = lazy(() => import('./Reports'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// 2. Component-level splitting (heavy widgets)
const HeavyChart = lazy(() => import('./HeavyChart'));
function DataPage({ showChart }) {
return (
<div>
<DataTable />
{showChart && (
<Suspense fallback={<div className="chart-skeleton" />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
// 3. Named exports with lazy (lazy requires default export)
const LazyModal = lazy(() =>
import('./components').then(module => ({ default: module.Modal }))
);
// 4. Preloading β trigger load before user navigates
function NavLink({ to, children }) {
const handleMouseEnter = () => {
// Start loading on hover, before click
import('./Dashboard');
};
return <Link to={to} onMouseEnter={handleMouseEnter}>{children}</Link>;
}
// 5. Suspense with data (React 18 + library support)
function UserProfile({ userId }) {
return (
<Suspense fallback={<Skeleton />}>
<UserDetails userId={userId} /> {/* throws a Promise while loading */}
</Suspense>
);
}
Code Walkthrough
- React.lazy() wraps a dynamic import() β the module must have a default export
- Suspense fallback renders while any lazy child is loading β nest Suspense for granular control
- Route-based splitting is the highest-impact code split β each page gets its own chunk
- Preloading on hover/focus gives faster perceived navigation β user already waited before clicking
- In React 18, Suspense works with any data that throws a Promise β libraries like SWR and React Query use this
Performance β Topic Progress
0 / 3 read
Optimisation Techniques
React 18 β Concurrent FeaturesReact 18+
βΌ
Definition: React 18 introduced Concurrent Rendering β React can prepare multiple versions of the UI simultaneously, pause rendering work, and prioritise updates. This unlocks non-blocking rendering: the UI stays responsive even during heavy state updates.
Key React 18 changes:
- Automatic Batching β setState calls are batched in async callbacks (setTimeout, fetch) β not just event handlers. Fewer re-renders.
- useTransition β mark state updates as non-urgent. UI stays responsive during the transition.
- useDeferredValue β defer re-rendering a value, keeping the current UI interactive
- Suspense improvements β streaming SSR, selective hydration
- New root API β
createRootinstead ofReactDOM.render
React 18 Features
// 1. Automatic batching (React 18)
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
// React 17: two renders in setTimeout
// React 18: ONE render (automatic batching) β
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
}
// 2. useTransition β mark update as non-urgent
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleInput = (e) => {
const val = e.target.value;
setQuery(val); // urgent β input updates immediately
startTransition(() => {
// non-urgent β React can pause this to handle user input
const filtered = hugeList.filter(item =>
item.name.toLowerCase().includes(val.toLowerCase())
);
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={handleInput} />
{isPending ? (
<Spinner />
) : (
<ResultsList results={results} />
)}
</div>
);
}
// 3. useDeferredValue β defer rendering of a value
function FilteredList({ items }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const isStale = filter !== deferredFilter;
// This expensive computation uses the deferred value
const filtered = useMemo(() => {
return items.filter(item => item.includes(deferredFilter));
}, [items, deferredFilter]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<List items={filtered} />
</div>
);
}
// 4. New root API (React 18)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
Code Walkthrough
- Automatic batching: React 18 batches all setState calls regardless of where they happen β reduces render count
- useTransition: the startTransition callback marks updates as interruptible β React can stop and prioritise urgent work (typing)
- isPending is true while the transition is in progress β use it to show a subtle loading indicator
- useDeferredValue is similar to useTransition but for values rather than the update site β useful when you can't modify where the state is set
- isStale check: when filter !== deferredFilter, show dimmed UI to indicate results are outdated
useTransition is for when you own the state setter. useDeferredValue is for when you receive a value from props you can't control.
Virtual DOM, Reconciliation & KeysImportant
βΌ
Definition: The Virtual DOM is a JavaScript representation of the DOM tree. React's Reconciliation algorithm (Fiber) diffs the new VDOM against the previous one and computes the minimum set of DOM operations needed. Keys help React identify which list items changed.
Reconciliation heuristics (how React diffs):
- Elements of different types β tear down the entire subtree and rebuild
- Same element type β update only changed attributes, recurse into children
- Lists without keys β diff by position (fragile β React may get it wrong)
- Lists with keys β diff by identity (correct and efficient)
- Keys must be unique among siblings (not globally)
- Keys must be stable β don't use Math.random() or array index for dynamic lists
- Using index as key is only safe for: static lists, append-only lists (never reorder/delete middle)
Keys & Reconciliation
// β Index as key β breaks with reordering/deletion
function BadList({ items }) {
return items.map((item, index) => (
// React uses position to match old/new items
// Deleting item[0] shifts all subsequent items β wrong state/animation
<TodoItem key={index} item={item} />
));
}
// β
Stable unique ID
function GoodList({ items }) {
return items.map(item => (
<TodoItem key={item.id} item={item} />
));
}
// β Random key β new key every render = remount every render
<Item key={Math.random()} /> // destroys and recreates on every render
// β
Using key to FORCE remount (intentional reset)
// Changing the key destroys the old component and creates a new one
// Useful for resetting a component's state when a prop changes
function ProfilePage({ userId }) {
return (
// When userId changes, ProfileEditor completely remounts
// All its internal state (form fields, etc.) resets
<ProfileEditor key={userId} userId={userId} />
);
}
// Reconciliation examples:
// CASE 1: Type change β full remount
<div> β <span> // div unmounts, span mounts
// CASE 2: Same type β just update props
<div className="old"> β <div className="new"> // only className changed
// CASE 3: Component type change β remount
<Button /> β <IconButton /> // Button unmounts, IconButton mounts
Code Walkthrough
- Keys are hints to the reconciler β 'this is the same logical item even if it moved position'
- Index keys cause subtle bugs: deleting item 0 causes item 1's key to become 0 β React thinks it's the same item, preserving wrong state
- Intentional key reset: changing a component's key is the correct way to force a full remount and reset all state
- Type change remounts: wrapping a component in different parents conditionally (div vs section) causes remount even if inner component is the same
- React Fiber splits reconciliation into small units β it can pause, resume, prioritise β this is the basis of Concurrent Mode
Code Splitting & Bundle OptimizationImportant
βΌ
Definition: Code splitting divides your JavaScript bundle into smaller chunks that are loaded on demand rather than all at once. Combined with tree-shaking (removing unused exports) and lazy loading, it dramatically reduces initial load time.
Strategies (highest to lowest impact):
- Route-based splitting β each page gets its own chunk (biggest win)
- Component-based splitting β heavy widgets (rich text editors, charts, date pickers)
- Vendor splitting β separate chunks for large dependencies (React, lodash)
- Dynamic imports β load modules only when a feature is used
Code Splitting Patterns
// 1. Dynamic import β load library only when needed
async function handleExport() {
// xlsx library (500KB) only loads when user clicks Export
const { utils, writeFile } = await import('xlsx');
const ws = utils.json_to_sheet(data);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, 'Data');
writeFile(wb, 'export.xlsx');
}
// 2. Conditional heavy component loading
const RichEditor = lazy(() => import('./RichTextEditor')); // 200KB
function PostEditor({ useRich }) {
if (!useRich) return <textarea />; // simple case loads nothing
return (
<Suspense fallback={<div>Loading editor...</div>}>
<RichEditor />
</Suspense>
);
}
// 3. Prefetch on interaction
function DashboardNav() {
const prefetchDashboard = () => import('./Dashboard'); // start load
return (
<Link
to="/dashboard"
onMouseEnter={prefetchDashboard} // preload on hover
onFocus={prefetchDashboard} // preload on focus (keyboard nav)
>
Dashboard
</Link>
);
}
// 4. Vite config β manual chunk splitting
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-utils': ['lodash', 'date-fns'],
'vendor-charts': ['recharts'],
}
}
}
}
};
// 5. Check bundle size before/after
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [visualizer({ open: true })], // opens treemap on build
});
Code Walkthrough
- Dynamic import() returns a Promise β the browser fetches the chunk asynchronously when first called
- Route-based splitting in React Router: each <Route> can lazy-load its component β users download only pages they visit
- Prefetching on mouseEnter: gives ~100-200ms head start on loading before the click, making navigation feel instant
- ManualChunks: group libraries that change infrequently into vendor chunks β they get cached separately from your app code
- Always check your bundle: npx vite build && npx vite preview β open the visualizer to find large dependencies
Start with route-based splitting. That alone usually reduces initial bundle by 40-60%. Then profile before adding more.
Next.js Basics β Topic Progress
0 / 4 read
Core Concepts
What is Next.js & Why Use ItCritical
βΌ
Definition: Next.js is a React framework that adds server-side rendering, file-based routing, API routes, image optimisation, and build tooling on top of React. It solves React's biggest production challenges out of the box.
Problems Next.js solves:
- SEO β Plain React (CSR) ships an empty HTML shell. Crawlers see nothing. Next.js pre-renders HTML on the server
- Performance β Static pages served from CDN, automatic code splitting, image optimisation
- Routing β No need for react-router β file structure defines routes
- API β Backend API routes in the same project
- DX β Zero-config TypeScript, fast refresh, built-in CSS modules
- SSR β HTML generated per request on the server (fresh data, good SEO)
- SSG β HTML generated at build time (fastest, served from CDN)
- ISR β SSG with background revalidation (best of both)
- CSR β Traditional React SPA (client fetches data after load)
- RSC β React Server Components (App Router) β render on server, zero JS shipped
Rendering Strategy Comparison
// SSG β generated at build time, cached on CDN
// Best for: blog posts, marketing pages, docs
export default async function BlogPost({ params }) {
const post = await getPost(params.slug); // called at BUILD TIME
return <article>{post.content}</article>;
}
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
// SSR β generated per request
// Best for: personalised pages, real-time data
export default async function Dashboard() {
const user = await getUser(); // called on EVERY REQUEST
return <div>Hello {user.name}</div>;
}
// ISR β static but refreshes in background
// Best for: product pages, news (data changes but not per-second)
async function getProduct(id) {
const res = await fetch(`/api/products/${id}`, {
next: { revalidate: 60 } // regenerate after 60 seconds
});
return res.json();
}
// CSR β client fetches after load
// Best for: user-specific data behind auth, dashboards
'use client';
function ProfilePage() {
const { data } = useSWR('/api/me', fetcher);
return <div>{data?.name}</div>;
}
Code Walkthrough
- SSG: data is fetched once at build time β pages are pre-built HTML files served instantly from CDN
- ISR: first visitor gets cached page, background job regenerates page after revalidate seconds
- SSR: every request hits your server β more expensive but always fresh
- RSC (App Router): components run on the server, fetch data directly, ship zero JavaScript to the client
- Choose the least dynamic option: SSG > ISR > SSR > CSR in terms of performance
App Router vs Pages RouterCritical
βΌ
Definition: Next.js has two routing systems: the original Pages Router (stable, widely used) and the newer App Router (Next.js 13+, recommended for new projects). They have different file conventions, data fetching patterns, and component models.
Key differences:
- App Router uses React Server Components by default β components run on the server unless marked 'use client'
- Pages Router all components are client-side React with server-side data fetching via getServerSideProps/getStaticProps
- Layouts β App Router has nested layouts that persist across navigation; Pages Router requires manual _app.js setup
- Data fetching β App Router: fetch() directly in async components; Pages Router: special export functions
- Streaming β App Router supports React 18 streaming with Suspense; Pages Router does not
Side-by-Side Comparison
// βββ PAGES ROUTER βββββββββββββββββββββββββββββββββββββββ
// pages/blog/[slug].js
export default function BlogPost({ post }) {
// Component always runs on CLIENT
return <article>{post.title}</article>;
}
// Data fetching via exported function
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return { props: { post }, revalidate: 60 };
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
};
}
// Layout in _app.js
function MyApp({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
// βββ APP ROUTER ββββββββββββββββββββββββββββββββββββββββββ
// app/blog/[slug]/page.tsx
// Server Component by default β runs on server, no JS shipped
export default async function BlogPost({ params }) {
const post = await getPost(params.slug); // direct DB/API call
return <article>{post.title}</article>;
}
// Static params for SSG
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
// Metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return { title: post.title, description: post.excerpt };
}
// Layout: app/layout.tsx β persistent across routes
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Nav />
{children}
<Footer />
</body>
</html>
);
}
Code Walkthrough
- Pages Router: getStaticProps/getServerSideProps run on server, pass data as props to the client component
- App Router: the component itself is async and runs on the server β much simpler and more natural
- generateStaticParams replaces getStaticPaths in App Router β same concept, cleaner API
- Layouts in App Router persist between route changes β Nav/Footer don't unmount and remount on navigation
- For new projects: use App Router. For existing Pages Router: no need to migrate unless you need streaming/RSC
File-Based Routing (App Router)Critical
βΌ
Definition: In Next.js App Router, the file system is the router. The
app/ directory structure maps directly to URL paths. Special filenames (page.tsx, layout.tsx, loading.tsx, error.tsx) have reserved meanings.Special files:
page.tsxβ the UI for a route, makes the segment publicly accessiblelayout.tsxβ shared UI wrapping a segment and its children, persists on navigationloading.tsxβ automatic Suspense boundary, shows while page is loadingerror.tsxβ error boundary for a segment (must be 'use client')not-found.tsxβ renders when notFound() is calledroute.tsβ API route handler (GET, POST, etc.)middleware.tsβ runs before every request
[slug], [...rest] (catch-all), [[...rest]] (optional catch-all)
Route groups: (group) β organise routes without affecting URLApp Directory Structure
app/
βββ layout.tsx β wraps everything (html, body)
βββ page.tsx β / (home page)
βββ loading.tsx β / loading state
βββ error.tsx β / error boundary
βββ not-found.tsx β 404 page
β
βββ blog/
β βββ layout.tsx β /blog/* shared layout (sidebar, nav)
β βββ page.tsx β /blog
β βββ [slug]/
β βββ page.tsx β /blog/my-post
β βββ opengraph-image.tsx β OG image
β
βββ (auth)/ β Route group β no URL segment
β βββ login/page.tsx β /login
β βββ register/page.tsx β /register
β
βββ dashboard/
β βββ layout.tsx β /dashboard/* shared layout
β βββ page.tsx β /dashboard
β βββ @analytics/ β Parallel route slot
β β βββ page.tsx
β βββ settings/
β βββ page.tsx β /dashboard/settings
β
βββ api/
βββ users/
βββ route.ts β /api/users (GET, POST handlers)
// βββ Dynamic route with params βββββββββββββββββββββββββββ
// app/blog/[slug]/page.tsx
interface Props {
params: { slug: string };
searchParams: { [key: string]: string };
}
export default async function Post({ params, searchParams }: Props) {
const { slug } = params;
const { sort } = searchParams; // URL: /blog/hello?sort=date
const post = await getPost(slug);
if (!post) notFound(); // triggers not-found.tsx
return <article>{post.content}</article>;
}
// Catch-all: app/docs/[...path]/page.tsx
// Matches /docs/a, /docs/a/b, /docs/a/b/c
// params.path = ['a', 'b', 'c']
Code Walkthrough
- layout.tsx wraps all child routes β Nav and Footer in root layout render on every page
- Route groups (auth) let you apply a different layout to login/register without those segments appearing in the URL
- loading.tsx is automatically wrapped in Suspense β it shows while the async page.tsx is fetching data
- error.tsx must be a Client Component β it receives error and reset props
- Parallel routes (@slot) let you render multiple pages in the same layout simultaneously β useful for modals, split views
Image, Font & Script OptimizationImportant
βΌ
Definition: Next.js provides built-in components for optimising the three most common causes of poor Core Web Vitals: unoptimised images (LCP), layout-shifting fonts (CLS), and render-blocking scripts.
next/image:
- Automatic WebP/AVIF conversion
- Resizes to exact display size (no oversized images)
- Lazy loads by default (only loads when in viewport)
- Prevents layout shift with required width/height
- Blur-up placeholder option
Image, Font & Script Components
// βββ next/image ββββββββββββββββββββββββββββββββββββββββββ
import Image from 'next/image';
// Static image β size automatically known
import profilePic from './profile.jpg';
<Image src={profilePic} alt="Profile" placeholder="blur" />
// Dynamic image β must specify size
<Image
src={user.avatarUrl}
alt={user.name}
width={64}
height={64}
className="rounded-full"
/>
// Hero image β priority loads eagerly (above the fold)
<Image
src="/hero.jpg"
alt="Hero"
fill // fills parent container
priority // disable lazy loading for LCP image
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
/>
// βββ next/font (App Router) βββββββββββββββββββββββββββββββ
import { Inter, JetBrains_Mono } from 'next/font/google';
import localFont from 'next/font/local';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter', // expose as CSS variable
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
});
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className={inter.className}>{children}</body>
</html>
);
}
// βββ next/script βββββββββββββββββββββββββββββββββββββββββ
import Script from 'next/script';
// afterInteractive (default) β loads after page hydration
<Script src="https://analytics.example.com/script.js" />
// lazyOnload β loads during browser idle time
<Script src="https://chat-widget.example.com" strategy="lazyOnload" />
// Worker β loads in Web Worker (off main thread)
<Script src="/third-party.js" strategy="worker" />
// beforeInteractive β loads before hydration (rare, blocking)
<Script src="/critical-polyfill.js" strategy="beforeInteractive" />
Code Walkthrough
- fill prop + parent position:relative makes image fill its container β great for responsive hero images
- priority on above-the-fold images marks them as high-priority fetches β critical for LCP score
- next/font downloads fonts at build time and self-hosts them β Google Fonts requests never leave your CDN
- CSS variable approach (variable='--font-inter') lets you use the font in Tailwind or CSS: font-family: var(--font-inter)
- Script strategy: afterInteractive is right for analytics; lazyOnload for chat widgets; never put GA in beforeInteractive
App Router β Topic Progress
0 / 6 read
Server & Client Components
Server Components vs Client ComponentsCritical
βΌ
Definition: In the App Router, components are Server Components by default β they run on the server, can access databases/APIs directly, and ship zero JavaScript to the browser. Client Components are marked with
'use client' and run in both server (SSR) and browser.Server Components CAN:
- Use async/await directly β
async function Page() - Access databases, filesystems, environment variables directly
- Import server-only packages (no bundle size impact)
- Pass data to Client Components as props
- Use hooks (useState, useEffect, etc.)
- Use browser APIs (window, document)
- Use event handlers (onClick, onChange)
- Use Context (as providers)
- Run on server for initial SSR HTML, then hydrate in browser
- Can use hooks, event handlers, browser APIs
- Increase JavaScript bundle size
Server vs Client Components
// βββ SERVER COMPONENT (default) ββββββββββββββββββββββββββ
// app/dashboard/page.tsx β no 'use client', async allowed
import { db } from '@/lib/db';
export default async function DashboardPage() {
// Direct DB query β runs on server, never exposed to client
const user = await db.user.findUnique({ where: { id: getUserId() } });
const stats = await db.analytics.aggregate({ _sum: { views: true } });
// Pass serialisable data to Client Component
return (
<main>
<h1>Welcome, {user.name}</h1>
<Stats data={stats} /> {/* Server Component */}
<InteractiveChart data={stats} /> {/* Client Component */}
</main>
);
}
// βββ CLIENT COMPONENT ββββββββββββββββββββββββββββββββββββ
'use client'; // This directive propagates to all imports
import { useState, useEffect } from 'react';
interface ChartProps {
data: { views: number }; // must be serialisable (no Functions, Dates as-is)
}
export function InteractiveChart({ data }: ChartProps) {
const [period, setPeriod] = useState('7d');
return (
<div>
<select value={period} onChange={e => setPeriod(e.target.value)}>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
{/* chart rendering */}
</div>
);
}
// βββ PASSING SERVER COMPONENT AS CHILDREN ββββββββββββββββ
// β
This works β Server Component passed via children prop
function Layout({ children }) { // Client Component
return <div className="layout">{children}</div>;
}
// In a Server Component:
<Layout>
<ServerSidebar /> {/* Server Component passed as prop */}
</Layout>
// βββ server-only package β prevents accidental client import ββ
import 'server-only'; // throws if imported in client bundle
export async function getSecretData() {
return process.env.SECRET_KEY; // safe
}
Code Walkthrough
- 'use client' is a boundary β it marks the component and all its imports as client-side
- Server Components can import server-only packages (like database drivers) β they won't bloat the client bundle
- Data passed from Server to Client Component must be serialisable β no functions, no class instances, use plain objects/arrays
- The children pattern: Server Components can pass Server Component output to Client Components β allows composition without losing RSC benefits
- server-only package: import it at the top of server-only modules to get a build error if accidentally imported client-side
'use client' propagates down the import tree β every module imported by a Client Component also becomes client-side. Keep your client boundary as deep in the tree as possible.
Data Fetching in App RouterCritical
βΌ
Definition: In the App Router, data fetching is done by making async Server Components that call
fetch() or database clients directly. Next.js extends the native fetch API with caching and revalidation options.Fetch caching behaviour:
fetch(url)β cached indefinitely (static, SSG behaviour)fetch(url, { cache: 'no-store' })β never cached (dynamic, SSR behaviour)fetch(url, { next: { revalidate: 60 } })β cached, revalidates after 60s (ISR behaviour)fetch(url, { next: { tags: ['posts'] } })β cached, revalidates when tag is invalidated
Data Fetching Patterns
// 1. Basic server component data fetching
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // ISR: revalidate every hour
}).then(r => r.json());
return <PostList posts={posts} />;
}
// 2. Parallel fetching β don't await sequentially (waterfall)
export default async function Dashboard() {
// β Sequential β total wait = 200ms + 150ms = 350ms
// const user = await getUser();
// const posts = await getPosts();
// β
Parallel β total wait = max(200ms, 150ms) = 200ms
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
]);
return <DashboardUI user={user} posts={posts} analytics={analytics} />;
}
// 3. Streaming with Suspense β show page while slow parts load
import { Suspense } from 'react';
export default function Page() {
return (
<main>
<h1>Dashboard</h1> {/* renders immediately */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</main>
);
}
async function SlowStats() {
const stats = await getSlowAnalytics(); // takes 2s
return <StatsDisplay stats={stats} />;
}
// 4. Cache deduplication with React cache()
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
console.log('DB query for user', id); // only logged once per request
return db.user.findUnique({ where: { id } });
});
// Both components call getUser β only ONE DB query happens
async function Header() { const user = await getUser('123'); ... }
async function Sidebar() { const user = await getUser('123'); ... }
// 5. On-demand revalidation (after mutation)
import { revalidatePath, revalidateTag } from 'next/cache';
async function createPost(formData: FormData) {
'use server';
await db.post.create({ data: parseForm(formData) });
revalidatePath('/blog'); // invalidate /blog page
revalidateTag('posts'); // invalidate all 'posts' tagged fetches
}
Code Walkthrough
- Promise.all for parallel fetching is critical β sequential awaits create a waterfall that multiplies latency
- Streaming with Suspense: the HTML for the page shell is sent immediately, slow sections stream in as they resolve
- React cache() is per-request memoization β the same DB query in multiple components only hits the database once
- revalidatePath/revalidateTag: call after mutations (form submissions, API calls) to clear cached data
- Dynamic rendering: if any part of the route uses cookies(), headers(), or searchParams, the entire route is dynamic (SSR)
Server ActionsReact 18+
βΌ
Definition: Server Actions are async functions that run on the server and can be called from Client Components. They replace the need for API routes for mutations β form submissions, creating/updating/deleting data. Marked with
'use server'.Benefits:
- No need to create API routes for mutations
- Type-safe β same TypeScript types server and client
- Works with native
<form>action attribute β progressively enhanced (works without JS) - Integrated with Next.js caching β call revalidatePath/revalidateTag after mutation
Server Actions Patterns
// βββ Server Action in a separate file ββββββββββββββββββββ
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
// Runs on the server
const raw = {
title: formData.get('title'),
content: formData.get('content'),
};
const validated = CreatePostSchema.safeParse(raw);
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: validated.data });
revalidatePath('/blog'); // clear cached blog page
redirect(`/blog/${post.slug}`); // navigate to new post
}
// βββ Using in a Client Component form ββββββββββββββββββββ
'use client';
import { createPost } from './actions';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
const initialState = { error: null };
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" required />
{state.error?.title && <span>{state.error.title}</span>}
<textarea name="content" required />
{state.error?.content && <span>{state.error.content}</span>}
<SubmitButton />
</form>
);
}
// βββ Server Action called programmatically ββββββββββββββββ
'use client';
import { deletePost } from './actions';
function PostCard({ post }) {
return (
<div>
<h2>{post.title}</h2>
{/* Call directly β no form needed */}
<button onClick={() => deletePost(post.id)}>Delete</button>
</div>
);
}
Code Walkthrough
- 'use server' at the top of a file makes all exported functions Server Actions β or put it inside a function for inline actions
- formData.get() extracts form fields by name attribute β matches HTML input name attributes
- Always validate input on the server β never trust client data. Zod is the go-to schema validation library
- useFormState tracks the return value of the action, useFormStatus tracks pending state of the form
- redirect() must be called outside try/catch β it throws an internal Next.js error that needs to propagate
- revalidatePath/revalidateTag after mutations ensures users see fresh data immediately
Layouts, Loading & Error UIImportant
βΌ
Definition: Next.js App Router has built-in conventions for persistent layouts, loading states, and error boundaries β each mapped to special filenames in the app directory. They enable instant navigation, streaming, and granular error handling.
Layout behaviour:
- Layouts are not re-rendered on navigation β Nav, Sidebar, state all persist
- Root layout (
app/layout.tsx) must include<html>and<body> - Nested layouts wrap their segment β
app/dashboard/layout.tsxwraps all dashboard pages
Layout, Loading, Error Files
// app/layout.tsx β root layout (required)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
{children} {/* page renders here */}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx β dashboard-specific layout
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Sidebar /> {/* persists across /dashboard/* */}
<main>{children}</main>
</div>
);
}
// app/dashboard/loading.tsx β automatic Suspense fallback
// Shows while app/dashboard/page.tsx is fetching data
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<SkeletonCard />
<SkeletonCard />
<SkeletonTable />
</div>
);
}
// app/dashboard/error.tsx β error boundary for /dashboard/*
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error); // or Sentry.captureException(error)
}, [error]);
return (
<div className="error-page">
<h2>Something went wrong in Dashboard</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/not-found.tsx β shown when notFound() is called
export default function NotFound() {
return (
<div>
<h2>404 β Page Not Found</h2>
<Link href="/">Go Home</Link>
</div>
);
}
// In a page β trigger not-found
import { notFound } from 'next/navigation';
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
if (!post) notFound();
return <Post post={post} />;
}
Code Walkthrough
- The root layout is the only required layout β it must have html and body tags (Next.js doesn't add them automatically)
- Nested layouts compose: root layout wraps dashboard layout wraps page β each layer can add its own UI
- loading.tsx is automatically a Suspense boundary β you don't need to wrap your page in Suspense manually
- error.tsx receives reset β calling it retries rendering the segment (great for transient errors)
- error.tsx does NOT catch errors in the layout β create a global-error.tsx for root layout errors
Route Handlers (API Routes)Important
βΌ
Definition: Route Handlers (
app/api/*/route.ts) are the App Router equivalent of API routes. They're standard Web API Request/Response functions that can handle any HTTP method: GET, POST, PUT, DELETE, PATCH.When to use Route Handlers:
- Third-party webhooks (Stripe, GitHub)
- Serving data to external clients / mobile apps
- When you need fine-grained control over headers, status codes, streaming
- OAuth callbacks
Route Handler Patterns
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// GET /api/posts or GET /api/posts?limit=10
export async function GET(request: NextRequest) {
const limit = request.nextUrl.searchParams.get('limit') ?? '10';
const posts = await db.post.findMany({
take: parseInt(limit),
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(posts);
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate auth
const session = await getSession(request);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const post = await db.post.create({ data: { ...body, userId: session.userId } });
return NextResponse.json(post, { status: 201 });
}
// app/api/posts/[id]/route.ts β dynamic segment
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const post = await db.post.findUnique({ where: { id: params.id } });
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(post);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.post.delete({ where: { id: params.id } });
return new NextResponse(null, { status: 204 });
}
// Setting headers, CORS
export async function GET(request: NextRequest) {
const data = await fetchData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, max-age=60',
'Access-Control-Allow-Origin': '*',
},
});
}
Code Walkthrough
- Route Handlers use standard Web API Request/Response β works with fetch(), no framework-specific syntax
- Export named functions matching HTTP methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
- NextRequest extends Request with helpers like nextUrl.searchParams for easy query param parsing
- NextResponse extends Response with helpers like NextResponse.json() and NextResponse.redirect()
- For Stripe webhooks: use request.text() to get raw body for signature verification β don't parse as JSON first
MiddlewareImportant
βΌ
Definition: Middleware runs before a request is processed, on every request that matches the matcher config. It runs at the Edge (globally distributed, fast) and can redirect, rewrite, modify headers, or block requests.
Common use cases:
- Authentication β redirect to /login if no session
- Internationalisation β redirect to /en or /fr based on Accept-Language
- A/B testing β rewrite to variant URL based on cookie
- Rate limiting β block IPs with too many requests
- Feature flags β redirect users based on feature flags
Middleware Examples
// middleware.ts β in project root
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Authentication check
const token = request.cookies.get('auth-token')?.value;
const isProtectedRoute = pathname.startsWith('/dashboard');
if (isProtectedRoute && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname); // remember where they were
return NextResponse.redirect(loginUrl);
}
// 2. Redirect logged-in users away from auth pages
const isAuthRoute = pathname.startsWith('/login') || pathname.startsWith('/register');
if (isAuthRoute && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 3. Rewrite (serve different content, URL unchanged)
if (pathname.startsWith('/old-blog')) {
return NextResponse.rewrite(new URL(pathname.replace('/old-blog', '/blog'), request.url));
}
// 4. Add custom headers
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
}
// Config: which routes middleware runs on
export const config = {
matcher: [
// Match all except static files and api routes
'/((?!_next/static|_next/image|favicon.ico|api).*)',
// Or be explicit:
'/dashboard/:path*',
'/profile/:path*',
],
};
Code Walkthrough
- Middleware runs before auth checks in page files β centralize all auth redirects here
- NextResponse.redirect() changes the URL; NextResponse.rewrite() changes the content but keeps the URL
- The matcher is critical for performance β don't run middleware on _next/static (would slow every image/CSS request)
- Edge Runtime: no Node.js APIs (no fs, no bcrypt) β use JWT verification with jose (Edge-compatible) not jsonwebtoken
- For complex auth: validate JWT in middleware for speed, but still verify session in server components before accessing sensitive data
Next.js Advanced β Topic Progress
0 / 5 read
Production Patterns
Authentication with NextAuth.js / Auth.jsImportant
βΌ
Definition: Auth.js (NextAuth v5) is the most popular authentication library for Next.js. It supports 50+ OAuth providers (Google, GitHub), credentials (email/password), magic links, and integrates seamlessly with the App Router via Server Actions and Middleware.
Core concepts:
- Session β JWT or database-backed user session
- Provider β OAuth provider config (Google, GitHub) or Credentials
- Callbacks β customise token/session content
- Middleware β protect routes before rendering
NextAuth.js Setup
// auth.ts β core config
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
async authorize(credentials) {
const user = await db.user.findUnique({
where: { email: credentials.email as string }
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password as string, user.password);
return valid ? user : null;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) token.role = user.role; // add role to JWT
return token;
},
async session({ session, token }) {
session.user.role = token.role; // expose in session
return session;
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
});
// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;
// middleware.ts β protect routes
export { auth as middleware } from '@/auth';
export const config = { matcher: ['/dashboard/:path*'] };
// In a Server Component
import { auth } from '@/auth';
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect('/login');
return <div>Hello {session.user.name}</div>;
}
// Sign in / out buttons (Server Action)
import { signIn, signOut } from '@/auth';
export function SignInButton() {
return (
<form action={async () => { 'use server'; await signIn('google'); }}>
<button type="submit">Sign in with Google</button>
</form>
);
}
Code Walkthrough
- The auth() function works in both Server Components and Route Handlers β returns the current session
- JWT callback runs every time a JWT is created/updated β add custom fields (role, id) here
- Session callback shapes the session object available to the client β expose only what's needed
- Middleware auth export is the simplest way to protect entire route segments
- Always hash passwords with bcrypt (cost factor 10-12) β never store plain text passwords
Environment VariablesImportant
βΌ
Definition: Next.js loads environment variables from
.env files. Variables prefixed with NEXT_PUBLIC_ are exposed to the browser. All other variables are server-only β never exposed to the client bundle.File precedence (highest to lowest):
.env.localβ local overrides (gitignored).env.developmentor.env.productionβ environment-specific.envβ defaults
undefined, not an error.Environment Variables
// .env.local (gitignored β secrets here)
DATABASE_URL=postgresql://user:pass@localhost/mydb
NEXTAUTH_SECRET=supersecretkey
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
// βββ Server-only (no NEXT_PUBLIC_ prefix) ββββββββββββββββ
// app/api/payment/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // server only β
// βββ Client-accessible (NEXT_PUBLIC_ prefix) βββββββββββββ
'use client';
// Available in browser β treat as public
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// βββ Type safety with T3 env approach ββββββββββββββββββββ
// env.mjs β validate at startup
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
// Usage β typed, validated at startup
import { env } from '@/env.mjs';
const db = new Client(env.DATABASE_URL); // TypeScript knows this exists
Code Walkthrough
- NEXT_PUBLIC_ variables are inlined at build time β they're hardcoded in the client bundle, not secret
- Server variables (no prefix) are available in Server Components, Route Handlers, middleware, and getServerSideProps
- Never import a server-only module in a Client Component β add import 'server-only' to enforce this
- @t3-oss/env-nextjs validates env vars at startup β fail fast instead of mysterious undefined errors in production
- In Vercel: add env vars in project settings. They override .env files. Use different values per environment (dev/preview/prod)
TypeScript with Next.jsImportant
βΌ
Definition: Next.js has zero-config TypeScript support. The App Router is built TypeScript-first with typed params, searchParams, metadata, and route handlers. TypeScript errors are shown in the browser overlay during development.
Key types to know:
NextPageβ type for Pages Router page componentsMetadataβ type for the metadata exportNextRequest/NextResponseβ typed request/response for middleware and route handlersPagePropsβ params and searchParams for App Router pages
TypeScript Patterns in Next.js
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface Props {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
// Typed page component
export default async function BlogPost({ params, searchParams }: Props) {
const { slug } = params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
// Typed metadata generation
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.slug);
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.coverImage, ...previousImages],
},
};
}
// app/api/users/[id]/route.ts β typed route handler
import { NextRequest, NextResponse } from 'next/server';
type RouteContext = {
params: { id: string };
};
export async function GET(req: NextRequest, { params }: RouteContext) {
const user = await getUser(params.id);
return NextResponse.json(user);
}
// Typing Server Actions
'use server';
type FormState = { success: boolean; error?: string };
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
const email = formData.get('email') as string;
await db.user.create({ data: { email } });
return { success: true };
} catch (e) {
return { success: false, error: 'Failed to create user' };
}
}
// next.config.ts β typed config
import type { NextConfig } from 'next';
const config: NextConfig = {
images: { domains: ['images.unsplash.com'] },
experimental: { serverActions: { allowedOrigins: ['localhost:3000'] } },
};
export default config;
Code Walkthrough
- params and searchParams in page.tsx are always strings β parse numbers/booleans explicitly
- generateMetadata can be async β it can fetch data to generate dynamic titles and OG images
- NextRequest extends the standard Request type β use it in middleware and route handlers
- TypeScript strict mode is on by default in new Next.js projects β don't disable it
- Use 'as string' for formData.get() or check for null β it returns string | File | null
Performance & Core Web VitalsImportant
βΌ
Definition: Core Web Vitals are Google's metrics for user experience: LCP (loading), INP (interactivity), and CLS (visual stability). Next.js provides built-in optimisations but you must use them correctly.
Common optimisation checklist:
- LCP β Add
priorityto hero image, usenext/image, reduce TTFB (use edge functions for SSR) - CLS β Always specify image dimensions, use
next/fontto eliminate FOUT, avoid inserting DOM above existing content - INP β Break up long tasks (useTransition), reduce JavaScript bundle (code split, remove unused deps), defer non-critical scripts
Performance Optimisation Patterns
// 1. Bundle analysis β find large dependencies
// package.json
"scripts": {
"analyze": "ANALYZE=true next build"
}
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
// 2. Optimised image with proper sizing
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // preload β critical for LCP
quality={85} // 85 = good quality + smaller file
sizes="(max-width: 768px) 100vw, 1200px"
/>
// 3. Preload critical data at the root
// app/layout.tsx
export default async function RootLayout({ children }) {
const user = await getCurrentUser(); // fetch once at layout level
return (
<html>
<body>
<UserProvider user={user}>
{children}
</UserProvider>
</body>
</html>
);
}
// 4. Streaming for perceived performance
export default function Page() {
return (
<>
<ImportantAboveFold /> {/* renders immediately */}
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* streams in */}
</Suspense>
</>
);
}
// 5. Route-level performance config
export const dynamic = 'force-static'; // SSG
export const revalidate = 3600; // ISR every hour
export const runtime = 'edge'; // Edge Runtime (faster cold start)
// 6. Partial Prerendering (Next.js 14+)
// Static shell with dynamic holes (best of SSG + SSR)
import { unstable_noStore as noStore } from 'next/cache';
async function DynamicSection() {
noStore(); // this component is always dynamic
const data = await fetchPersonalizedData();
return <div>{data}</div>;
}
// Static wrapper β shell is prerendered, dynamic section streams
export default function Page() {
return (
<main>
<StaticHero />
<Suspense fallback={<Skeleton />}>
<DynamicSection />
</Suspense>
</main>
);
}
Code Walkthrough
- export const dynamic controls rendering strategy at the route level β cleaner than per-fetch cache options
- Streaming: the browser starts rendering the page shell immediately, without waiting for all data to load
- Partial Prerendering (PPR): the static parts are served from CDN instantly, dynamic holes stream in β best of both worlds
- Core Web Vitals measurement: use Next.js Analytics, Google Search Console, or Vercel Speed Insights
- For edge runtime: verify your database adapter supports Edge (Neon, PlanetScale, Upstash all do; pg/mysql2 do not)
Testing Next.js ApplicationsImportant
βΌ
Definition: Testing a Next.js app requires different tools for different layers: Jest + React Testing Library for unit and component tests, Playwright or Cypress for end-to-end tests. Server Components and Server Actions need specific testing approaches.
Testing pyramid for Next.js:
- Unit tests β utility functions, server actions, pure components (Jest)
- Component tests β React components with RTL (React Testing Library)
- Integration tests β page behaviour with MSW for API mocking
- E2E tests β full user flows in a real browser (Playwright)
Testing Patterns
// jest.config.ts
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({ dir: './' });
const config: Config = {
setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
};
export default createJestConfig(config);
// jest.setup.ts
import '@testing-library/jest-dom';
// βββ Testing a Client Component ββββββββββββββββββββββββββ
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments on click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
// βββ Testing a Server Action ββββββββββββββββββββββββββββββ
import { createPost } from './actions';
import { db } from '@/lib/db';
jest.mock('@/lib/db');
describe('createPost action', () => {
it('creates a post and returns it', async () => {
const mockPost = { id: '1', title: 'Test', content: 'Hello' };
(db.post.create as jest.Mock).mockResolvedValue(mockPost);
const formData = new FormData();
formData.set('title', 'Test');
formData.set('content', 'Hello');
const result = await createPost({}, formData);
expect(result.success).toBe(true);
expect(db.post.create).toHaveBeenCalledWith({ data: expect.objectContaining({ title: 'Test' }) });
});
});
// βββ Playwright E2E test ββββββββββββββββββββββββββββββββββ
import { test, expect } from '@playwright/test';
test('user can create a post', async ({ page }) => {
await page.goto('/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.click('button[type=submit]');
await page.goto('/posts/new');
await page.fill('[name=title]', 'My Test Post');
await page.fill('[name=content]', 'Hello world content');
await page.click('button[type=submit]');
await expect(page).toHaveURL(/\/posts\//);
await expect(page.getByText('My Test Post')).toBeVisible();
});
Code Walkthrough
- nextJest automatically sets up Next.js-specific transforms, mocks (next/router, next/navigation) and jest-dom
- userEvent.setup() is preferred over fireEvent β it simulates real user interactions (focus, keyboard, pointer)
- Server Actions are plain async functions β test them directly without rendering any UI
- Mock the database layer, not the action itself β you want to test the action logic, not the DB driver
- Playwright tests run against your actual running app β start with npx playwright test and use the --ui flag for debugging