FE-02 — React Performance Internals
Why Re-renders Happen, How to Stop Them, and When Not to Bother
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals), basic React knowledge.
Every optimisation in this book is derived from one fact: React re-renders more than you think, and most of the time it doesn't matter. The skill is knowing when it does.
Chapter 0: The Mental Model You Need Before Everything Else
React has one job: keep the UI in sync with state. When state changes, React re-renders the component that owns that state, and by default, every component below it in the tree.
Re-rendering is not the same as DOM updating.
This is the most important distinction in React performance. A re-render means React calls your component function again, gets a new JSX tree, runs reconciliation (diffs the new tree against the previous one), and then — only if something actually changed — updates the DOM.
State changes
→ Component function called again ← this is a "re-render"
→ New JSX tree produced
→ Reconciliation (diff)
→ DOM mutations (only what changed) ← this is expensive
Re-rendering itself is cheap. It is just calling a JavaScript function. The expense is in two places: (1) the reconciliation diff on a large tree, and (2) the DOM mutations at the end. In most applications, neither of these is your actual bottleneck.
The performance investigation always starts here: is there actually a problem? Perceived slowness has three possible causes in React:
- Too many re-renders (components re-render when they don't need to)
- Expensive work during a render (heavy computation inside a component function)
- DOM thrashing (too many DOM mutations at once, triggering layout/paint)
Before optimising anything, you must identify which one is happening. Applying memo to a component that re-renders 3 times a second is not an optimisation. Applying memo to a component that re-renders 300 times a second during a scroll is.
The rule before every optimisation: measure first. Profile first. Optimise second. Intuition is wrong more often than it is right in React performance.
Chapter 1: Why React Re-renders — The Complete Picture
A component re-renders in exactly four situations. Know these cold.
Situation 1: Its own state changed
function Counter() {
const [count, setCount] = useState(0)
// This component re-renders every time setCount is called
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
This is expected and correct. The component owns the state, it re-renders when state changes. No problem here.
Situation 2: Its parent re-rendered
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<Child /> {/* This re-renders EVERY TIME Parent re-renders */}
</div>
)
}
function Child() {
// This component has no state. No props that change.
// But it re-renders every time Parent re-renders.
console.log('Child rendered')
return <div>I am a child</div>
}
By default, when a parent re-renders, all children re-render. React does not check whether Child's props changed. It calls the Child function again. This is React's default behaviour and it is correct for most cases — the re-render is cheap, and the DOM update only happens if something actually changed.
This becomes a problem when: the child is expensive to render (complex tree, heavy computation), or the child re-renders hundreds of times per second during continuous user interaction.
Situation 3: A context it consumes changed
const ThemeContext = createContext('light')
function App() {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}>
<DeepChild /> {/* Re-renders whenever theme changes */}
<OtherChild /> {/* Also re-renders whenever theme changes */}
</ThemeContext.Provider>
)
}
function DeepChild() {
const theme = useContext(ThemeContext) // subscribed to ThemeContext
return <div className={theme}>I change with theme</div>
}
function OtherChild() {
const theme = useContext(ThemeContext) // also subscribed
// Even if OtherChild doesn't visually depend on theme,
// it re-renders every time theme changes because it called useContext
return <div>I don't actually use theme but I still re-render</div>
}
Every component that calls useContext(X) re-renders whenever X's value changes. Not just the components that visually depend on it — all consumers. This is the most common source of unnecessary re-renders in medium/large React apps.
Situation 4: Its own key prop changed
// Changing the key prop causes React to unmount the old component
// and mount a completely new one. Not a re-render — a remount.
<UserProfile key={userId} userId={userId} />
When key changes, React destroys the old component instance and creates a new one. All state is reset. This is usually intentional (we'll use this pattern deliberately in Chapter 7), but can happen accidentally if you use unstable values as keys (array indices, Math.random()).
Chapter 2: The React DevTools Profiler — Your Primary Diagnostic Tool
Before writing a single memo or useMemo, open the React DevTools Profiler and record. Everything else is guesswork.
Setting Up
Install React Developer Tools browser extension. In your app, ensure you are in development mode (not production) — the profiler is disabled in production builds.
Recording a Profile
- Open DevTools → Profiler tab
- Click the record button (circle)
- Perform the interaction that feels slow
- Click stop
Reading the Flame Graph
The flame graph shows the component tree as horizontal bars. The x-axis is time. The width of a bar represents how long that component took to render.
App ████████████████████████████ (100ms total)
├── Header █ (2ms)
├── Sidebar ██ (5ms)
└── Main █████████████████████████ (90ms — the problem is here)
└── PostList ████████████████████ (88ms)
└── Post (×50) ██ (1.7ms each)
What to look for:
- Wide bars = expensive components. Investigate these.
- Bars that appear on every interaction even when they shouldn't = unnecessary re-renders
- A component that re-renders 50× when you expected 5× = state or context issue
Colour coding in the flame graph:
- Grey = did not re-render in this commit
- Blue/yellow/red = did re-render (darker = slower)
- The "Why did this render?" tooltip — hover over any component bar to see the reason (prop changed, state changed, hooks changed, parent rendered)
The "Highlight updates" Checkbox
In the Components tab (not Profiler), there's a settings option: "Highlight updates when components render." Turn this on. As you interact with the app, components that re-render flash with a coloured border. If you see parts of the page flashing that you didn't expect, you've found unnecessary re-renders without even profiling.
Ranked Chart
Switch from Flame Graph to Ranked Chart. Components sorted by render time, slowest first. This immediately tells you where to focus. If the top 3 components account for 90% of render time, the other 97 components don't matter.
Mental model: The profiler shows you the symptom. The flame graph shows you where. The "Why did this render?" tooltip tells you the cause. Fixing without profiling is premature optimisation.
Chapter 3: React.memo — What It Does and When It Helps
The Problem It Solves
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<ExpensiveChild /> {/* Re-renders on every count change even though count isn't passed to it */}
</div>
)
}
function ExpensiveChild() {
// Imagine this renders a 200-node subtree. Expensive.
// But it has no props and no state. It should never need to re-render.
return <div>{/* complex 200-node subtree */}</div>
}
ExpensiveChild re-renders on every Parent re-render. Its output never changes because it has no inputs. This is wasted work.
What memo Does
const ExpensiveChild = React.memo(function ExpensiveChild() {
return <div>{/* complex 200-node subtree */}</div>
})
React.memo wraps a component in a memoisation layer. Before React calls the component function, it checks: did the props change? If props are the same as last render (by shallow comparison), React skips calling the function entirely and reuses the previous render output.
The component is now skipped whenever:
- Its props have not changed (shallow equal)
- Its parent re-renders
The component still re-renders when:
- Its own state changes (
useStateinside it) - Context it consumes changes (
useContext) - Its props change (any prop, by reference)
The Shallow Comparison Problem
memo uses shallow equality — it compares each prop with ===. For primitives (strings, numbers, booleans), === compares values. For objects, arrays, and functions, === compares references.
function Parent() {
const [count, setCount] = useState(0)
// ❌ This is a NEW object on every render. memo's comparison will fail.
const config = { theme: 'dark', size: 'large' }
// ❌ This is a NEW function on every render. Same problem.
const handleClick = () => console.log('clicked')
return <MemoChild config={config} onClick={handleClick} />
}
const MemoChild = React.memo(function MemoChild({ config, onClick }) {
return <div style={{ color: config.theme }}>{/* ... */}</div>
})
config is a new object literal every time Parent renders. {} === {} is false in JavaScript — two different object references are never strictly equal, even if their contents are identical. So memo's comparison fails, and MemoChild re-renders on every Parent re-render anyway. The memo wrapper is doing nothing.
This is the most common memo mistake. People add memo and wonder why the component still re-renders.
The fix is to stabilise the references — useMemo for objects, useCallback for functions. Covered in the next chapters.
When memo Genuinely Helps
memo helps when:
- The component is expensive to render (large subtree, heavy computation)
- The component re-renders frequently from parent re-renders
- The props it receives are stable references (primitives, or memoised objects/functions)
- All three of the above are true simultaneously
memo does NOT help when:
- Props change on every parent render anyway (the comparison fails, so React still re-renders)
- The component is cheap to render (the comparison cost exceeds the render cost)
- The component frequently re-renders from its own state (memo only prevents parent-triggered re-renders)
memo With a Custom Comparator
For cases where shallow comparison is insufficient:
const MemoChild = React.memo(
function MemoChild({ user }) {
return <div>{user.name}</div>
},
(prevProps, nextProps) => {
// Return true = props are equal = skip re-render
// Return false = props changed = re-render
return prevProps.user.id === nextProps.user.id
}
)
Use sparingly. A wrong custom comparator (returning true when props actually changed) causes stale UI — bugs that are very hard to find.
Mental model: memo is a bailout optimisation. It prevents re-renders when props haven't changed. It is only useful if: (a) the component is expensive, and (b) the props are stable references.
Chapter 4: useMemo — Stabilising References and Expensive Computations
useMemo has two distinct use cases. Most engineers only know one.
Use Case 1: Expensive Computation
function ProductList({ products, sortOrder }) {
// Without useMemo: this sort runs on EVERY render
// (including parent re-renders where neither products nor sortOrder changed)
const sorted = [...products].sort((a, b) => {
return sortOrder === 'asc' ? a.price - b.price : b.price - a.price
})
return sorted.map(p => <ProductCard key={p.id} product={p} />)
}
If products is 10,000 items, this sort runs on every render. If the parent re-renders 20 times because something unrelated changed, the sort runs 20 times unnecessarily.
function ProductList({ products, sortOrder }) {
// With useMemo: sort only runs when products or sortOrder changes
const sorted = useMemo(() => {
return [...products].sort((a, b) =>
sortOrder === 'asc' ? a.price - b.price : b.price - a.price
)
}, [products, sortOrder])
return sorted.map(p => <ProductCard key={p.id} product={p} />)
}
useMemo caches the result and only recomputes when dependencies change. For a 10,000-item sort, this is meaningful. For a 10-item sort, the useMemo overhead (dependency comparison, cache storage) likely exceeds the sort time — it would actually make things slower.
The threshold for memoising computation: if the computation takes less than ~1ms, useMemo is probably not worth it. For genuinely expensive work (complex calculations, large array transforms, parsing), it is.
Use Case 2: Stable Object References (the more important use case)
function Parent() {
const [count, setCount] = useState(0)
// Without useMemo: new object every render → memo on Child fails
const config = useMemo(() => ({
theme: 'dark',
size: 'large',
}), []) // No dependencies: created once, reference never changes
return <MemoChild config={config} />
}
useMemo with an empty dependency array creates the object once and returns the same reference forever. Now MemoChild's memo comparison sees the same config reference → skips re-render.
This is the primary reason to use useMemo in most apps. Not performance of the computation, but referential stability for memo'd children or useEffect dependencies.
// Another critical case: useMemo for useEffect dependencies
function UserComponent({ userId }) {
// Without useMemo: new options object every render
// → useEffect re-runs on every render (effect dependency changed)
const options = useMemo(() => ({
include: ['profile', 'settings'],
format: 'json',
}), [])
useEffect(() => {
fetchUser(userId, options) // options is now stable
}, [userId, options]) // effect only re-runs when userId changes
}
The Dependencies Array — The Source of All useMemo Bugs
const result = useMemo(() => compute(a, b), [a, b])
The dependencies array is a promise to React: "this memoised value only depends on a and b." If you use a value inside the computation but don't list it as a dependency, you get a stale closure — the computation uses an old value.
The ESLint rule react-hooks/exhaustive-deps catches this automatically. Enable it, respect it, never suppress it without understanding why.
// Bug: missing dependency
const result = useMemo(() => {
return items.filter(item => item.category === selectedCategory)
}, [items]) // ❌ missing selectedCategory — filter uses stale category
// Correct
const result = useMemo(() => {
return items.filter(item => item.category === selectedCategory)
}, [items, selectedCategory]) // ✓
Mental model: useMemo has two jobs — skip expensive computation, and stabilise object references. The second job is more commonly needed than the first.
Chapter 5: useCallback — Functions Are References Too
Every function defined inside a component body is a new function reference on every render.
function Parent() {
const [count, setCount] = useState(0)
const handleClick = () => {
console.log('clicked')
}
// handleClick is a new function object on every render
// handleClick === handleClick from previous render → FALSE
return <MemoChild onClick={handleClick} />
}
MemoChild receives a new onClick reference on every Parent render. memo's comparison sees a different function → re-renders every time. The memo wrapper is useless.
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // No dependencies: same function reference across all renders
return <MemoChild onClick={handleClick} />
}
useCallback(fn, deps) is exactly equivalent to useMemo(() => fn, deps). It caches a function reference across renders. handleClick is now the same reference every render. memo's comparison passes → MemoChild skips re-rendering.
When useCallback Actually Helps
Case 1: passing a callback to a memo'd child (as shown above)
Case 2: a function used as a useEffect dependency
// Without useCallback: new function every render → effect re-runs every render
function Component({ userId }) {
const fetchUser = async () => {
const user = await api.getUser(userId)
setUser(user)
}
useEffect(() => {
fetchUser()
}, [fetchUser]) // fetchUser is new every render → infinite loop
}
// With useCallback: function reference stable → effect runs only when userId changes
function Component({ userId }) {
const fetchUser = useCallback(async () => {
const user = await api.getUser(userId)
setUser(user)
}, [userId]) // recreate only when userId changes
useEffect(() => {
fetchUser()
}, [fetchUser]) // now stable
}
Case 3: event handlers passed to large lists
// Without useCallback: new handler function on every render
// Every PostCard re-renders because onClick reference changes
function PostList({ posts }) {
const handleLike = (postId) => {
likePost(postId)
}
return posts.map(post =>
<PostCard key={post.id} post={post} onLike={handleLike} />
)
}
// With useCallback: same reference, PostCard memo works
function PostList({ posts }) {
const handleLike = useCallback((postId) => {
likePost(postId)
}, []) // likePost is a stable external function
return posts.map(post =>
<PostCard key={post.id} post={post} onLike={handleLike} />
)
}
When useCallback Does NOT Help
// This is pointless — Counter has no memo'd children receiving this function
function Counter() {
const [count, setCount] = useState(0)
// Wrapping in useCallback when nothing benefits from reference stability
// You're just adding overhead with zero benefit
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
return <button onClick={increment}>{count}</button>
}
useCallback has a cost: dependency comparison on every render, and memory to store the cached function. Wrapping every function in useCallback by habit adds cost without benefit when the function is only used inline, not passed to memo'd children or useEffect.
Mental model: useCallback is useMemo for functions. Use it only when reference stability matters — i.e., when the function is passed to a memo'd component or used as a useEffect dependency.
Chapter 6: The Context Re-render Problem — The Hidden Performance Killer
How Context Works Internally
const UserContext = createContext(null)
function App() {
const [user, setUser] = useState({ name: 'Alice', role: 'admin' })
return (
<UserContext.Provider value={user}>
<Header /> {/* uses context */}
<Sidebar /> {/* uses context */}
<Main /> {/* uses context */}
<Footer /> {/* uses context */}
</UserContext.Provider>
)
}
When user state changes, App re-renders. A new value object is passed to the Provider. React notifies every consumer — every component that called useContext(UserContext) — and re-renders them all, regardless of whether the specific part of user they use actually changed.
If Header only uses user.name and user.role changes, Header still re-renders. React does not do property-level granularity in Context.
The Scale Problem
// This is common in large apps
const AppContext = createContext(null)
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setNotifications] = useState([])
const [sidebarOpen, setSidebarOpen] = useState(true)
// ❌ One context with everything
// Every change to any value re-renders every consumer
return (
<AppContext.Provider value={{ user, theme, notifications, sidebarOpen, setTheme, setSidebarOpen }}>
{/* 50+ components that consume AppContext */}
</AppContext.Provider>
)
}
sidebarOpen changes when the user toggles it. Every component consuming AppContext re-renders — including components that only care about user or theme. On a 50-component tree, this is significant.
Fix 1: Split Contexts by Update Frequency
// Separate contexts by how often they change
const UserContext = createContext(null) // Changes rarely (login/logout)
const ThemeContext = createContext('light') // Changes occasionally
const UIContext = createContext(null) // Changes frequently (sidebar, modal)
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [ui, setUI] = useState({ sidebarOpen: true })
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<UIContext.Provider value={{ ...ui, setUI }}>
{children}
</UIContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
)
}
Now sidebarOpen changing only re-renders components consuming UIContext. Components consuming UserContext are unaffected.
Fix 2: Memoize the Context Value
function App() {
const [user, setUser] = useState(null)
const [count, setCount] = useState(0) // unrelated state
// Without useMemo: new value object every render
// → all consumers re-render when count changes (even though user didn't)
const userValue = useMemo(() => ({ user, setUser }), [user])
return (
<UserContext.Provider value={userValue}>
{/* count can change freely without re-rendering UserContext consumers */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<UserAwareComponents />
</UserContext.Provider>
)
}
useMemo on the context value ensures consumers only re-render when user actually changes, not when any other state in the Provider's component changes.
Fix 3: Use Zustand or Jotai Instead of Context for Frequently Changing State
Context is designed for data that rarely changes (theme, auth, locale). For frequently changing shared state (filters, UI state, real-time data), use a signal-based store.
// Zustand: components only re-render when their slice of state changes
import { create } from 'zustand'
const useStore = create((set) => ({
user: null,
sidebarOpen: true,
notifications: [],
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
}))
// This component ONLY re-renders when sidebarOpen changes
// Not when user changes, not when notifications change
function Sidebar() {
const { sidebarOpen, toggleSidebar } = useStore(
state => ({ sidebarOpen: state.sidebarOpen, toggleSidebar: state.toggleSidebar })
)
return <aside className={sidebarOpen ? 'open' : 'closed'}>...</aside>
}
Zustand uses selector functions. The component re-renders only when the selected slice changes. This is fine-grained reactivity that Context cannot provide.
Mental model: Context is a broadcast channel. Every subscriber hears every message. Use it for data that changes rarely. For data that changes often, use a store that supports selectors.
Chapter 7: The key Prop Beyond Lists — Intentional Remounting
Most engineers know key as "the thing React needs for list items." It is more powerful than that.
Changing key destroys and remounts the component. All state is reset. All effects are re-run. It is as if the component was removed from the tree and a new one was inserted.
Use Case: Resetting Component State When Props Change
// Problem: UserProfile has internal state (active tab, scroll position)
// When userId changes, we want a fresh component — not the old state persisted
function UserProfile({ userId }) {
const [activeTab, setActiveTab] = useState('posts') // stale if userId changes
// Without key: switching users shows the previous user's active tab
return (
<div>
<Tabs value={activeTab} onChange={setActiveTab} />
</div>
)
}
// Usage without key — state persists across user changes:
<UserProfile userId={selectedUserId} />
// Fix: use userId as key — remounts when userId changes, state resets
<UserProfile key={selectedUserId} userId={selectedUserId} />
Without key={userId}, switching from user A to user B keeps user A's activeTab state in UserProfile. With key={userId}, React sees a component with a different key — unmounts the old, mounts new with fresh state.
Use Case: Force Re-fetching When an ID Changes
// Sometimes you want to force complete re-initialisation
// key on a parent resets the entire subtree
function OrderDetail({ orderId }) {
return (
// When orderId changes, this entire subtree remounts
// All child state, effects, and subscriptions reset
<div key={orderId}>
<OrderHeader orderId={orderId} />
<OrderItems orderId={orderId} />
<OrderTimeline orderId={orderId} />
</div>
)
}
Mental model: key is a component identity. Same key = same instance (state persists). Different key = new instance (state resets). Use it intentionally to reset state when the "thing being displayed" fundamentally changes.
Chapter 8: useTransition and useDeferredValue — Concurrent React for Performance
The Problem: Urgent vs Non-Urgent Updates
Not all state updates are equally important. A user typing in an input expects the input to feel instant. The search results that filter based on that input can be slightly delayed — they don't need to feel instant, just reasonably fast.
Without Concurrent React, both updates have equal priority. If filtering 10,000 items takes 50ms, the input itself feels laggy — React is busy re-rendering the results, and the user's keystrokes are delayed.
// The problem
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
function handleInput(e) {
const value = e.target.value
setQuery(value) // urgent: update input immediately
setResults(allItems.filter(i => i.includes(value))) // expensive: 10k item filter
// Both updates are synchronous. The filter blocks the input update.
// User perceives laggy input.
}
return (
<>
<input value={query} onChange={handleInput} />
<ResultsList results={results} />
</>
)
}
useTransition — Mark Updates as Non-Urgent
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
const [isPending, startTransition] = useTransition()
function handleInput(e) {
const value = e.target.value
setQuery(value) // urgent: runs synchronously, input updates immediately
startTransition(() => {
// Non-urgent: React can interrupt this if a more urgent update arrives
setResults(allItems.filter(i => i.name.toLowerCase().includes(value)))
})
}
return (
<>
<input value={query} onChange={handleInput} />
{isPending && <span>Updating...</span>}
<ResultsList results={results} />
</>
)
}
startTransition tells React: "this update is non-urgent. If the user types again before this finishes, abandon this render and start a new one with the latest input." The input stays responsive. The results update when the CPU has capacity.
isPending is true while the transition is in-flight — use it to show a loading indicator.
useDeferredValue — Defer a Value Without Controlling the Setter
useTransition requires you to control the state setter. When you don't (e.g., the state is managed by a parent or a library), use useDeferredValue.
function SearchPage({ query }) { // query comes from parent, we don't control it
// deferredQuery lags behind query
// During the lag, React renders with the old deferredQuery
// When it catches up, React renders with the new deferredQuery
const deferredQuery = useDeferredValue(query)
const results = useMemo(
() => allItems.filter(i => i.name.includes(deferredQuery)),
[deferredQuery] // only recomputes when deferredQuery changes (not query)
)
const isStale = query !== deferredQuery // true while deferred value is behind
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList results={results} />
</div>
)
}
useDeferredValue keeps deferredQuery behind query. When query updates rapidly (fast typing), deferredQuery only catches up when the browser is not busy. The expensive useMemo computation only runs on the deferred value, not on every keystroke.
The isStale check lets you show visual feedback (dimmed list) while the deferred value is catching up.
useTransition vs useDeferredValue — When to Use Which
- Control the state setter? →
useTransition - Don't control the setter (prop from parent, external library)? →
useDeferredValue - Want a
isPendingflag? →useTransition(provides it directly) - Wrapping a single value? →
useDeferredValueis simpler
Mental model: both defer expensive renders. useTransition wraps the setter. useDeferredValue wraps the value. They solve the same problem with different APIs depending on where the state lives.
Chapter 9: The Over-Memoisation Problem
This chapter exists because the previous chapters will make you want to wrap everything in memo, useMemo, and useCallback. That is the wrong conclusion.
The Cost of Memoisation
Nothing in React is free. Every memo, useMemo, and useCallback:
- Stores the previous result in memory
- Runs a comparison function on every render
- Adds cognitive overhead for every developer reading the code
For memo: React calls the comparator function on every parent re-render. If 100 memo'd children are re-evaluated on each render, that's 100 comparator calls — even if none of them re-render.
For useMemo and useCallback: React checks every dependency in the array on every render. With complex objects, this comparison is non-trivial.
The break-even point: memoisation saves cost only when the cost of the skipped computation/render exceeds the cost of the comparison. For simple components, this break-even is never reached.
The Profiler is the Arbiter
// Before adding memo:
function ProductCard({ product }) {
return (
<div>
<img src={product.image} alt={product.name} />
<h3>{product.title}</h3>
<p>{product.price}</p>
</div>
)
}
Profile this component. If it renders in 0.1ms and re-renders 10 times on a user interaction, the total cost is 1ms. Adding memo saves 0.8ms (the 8 skipped re-renders) but adds comparison overhead for 10 evaluations. Net gain: negligible.
If it renders in 15ms and re-renders 10 times, total cost is 150ms. Adding memo (assuming props are stable) reduces this to one 15ms render. Net gain: 135ms. Worth it.
The rule: if the profiler doesn't show the component as a significant contributor to render time, don't memoize it.
Common Over-Memoisation Mistakes
// Mistake 1: useCallback on a function that's only used inline
// The button doesn't benefit from reference stability
function Counter() {
const [n, setN] = useState(0)
const inc = useCallback(() => setN(n => n + 1), []) // pointless
return <button onClick={inc}>{n}</button>
}
// Mistake 2: useMemo on a cheap computation
const double = useMemo(() => n * 2, [n]) // pointless — multiplication is free
// Mistake 3: wrapping everything because "it might help"
// Every useMemo/useCallback call adds overhead that rarely helps
// and always adds cognitive noise
When to Definitely Memoize
- A component that renders in the Profiler as consistently slow (>5ms) and re-renders due to parent — use
memo - An object/array created inside a component that is passed to a
memo'd child or used as auseEffectdependency — useuseMemo - A function passed to a
memo'd child or used as auseEffectdependency — useuseCallback - A genuinely expensive computation (sorting/filtering 1000+ items) — use
useMemo
Mental model: the profiler is the only legitimate reason to add memoisation. "This might be slow" is not a reason. "The profiler shows this is slow" is.
Chapter 10: React.lazy and Suspense — Code Splitting at the Component Level
The Problem
Your dashboard has a rich text editor that only appears when a user clicks "Edit." The rich text editor library (tiptap, slate, quill) is 200KB. Every user who loads the dashboard downloads this 200KB even if they never click "Edit."
The Solution
import { lazy, Suspense } from 'react'
// The import is deferred — this code is not in the initial bundle
// It is fetched only when RichTextEditor is first rendered
const RichTextEditor = lazy(() => import('./RichTextEditor'))
function PostEditor({ post }) {
const [isEditing, setIsEditing] = useState(false)
return (
<div>
<h1>{post.title}</h1>
{isEditing ? (
// Suspense provides the fallback while the chunk is being downloaded
<Suspense fallback={<div>Loading editor...</div>}>
<RichTextEditor content={post.content} />
</Suspense>
) : (
<div>{post.content}</div>
)}
<button onClick={() => setIsEditing(true)}>Edit</button>
</div>
)
}
When the component first renders (dashboard load), RichTextEditor's code is not downloaded. When the user clicks "Edit," React tries to render RichTextEditor, finds it is not loaded yet, shows the Suspense fallback, and simultaneously downloads the chunk. When the download completes, the editor renders.
Preloading — Eliminating the Download Delay
The user clicks Edit → download starts → 300ms of loading spinner → editor appears. For a better experience, start the download before the user clicks.
// Preload the chunk when the user hovers over the Edit button
// (they're very likely to click if they hover)
const RichTextEditor = lazy(() => import('./RichTextEditor'))
// Trigger the import to start downloading the chunk
function preloadEditor() {
import('./RichTextEditor')
}
function PostEditor({ post }) {
const [isEditing, setIsEditing] = useState(false)
return (
<div>
<button
onClick={() => setIsEditing(true)}
onMouseEnter={preloadEditor} // start downloading on hover
>
Edit
</button>
{isEditing && (
<Suspense fallback={<div>Loading...</div>}>
<RichTextEditor content={post.content} />
</Suspense>
)}
</div>
)
}
Hover → download starts. Click → download likely already complete → editor appears instantly.
Mental model: lazy makes a component's code load on demand. Suspense handles the loading state. Hover-triggered preload eliminates the delay.
Chapter 11: List Rendering Optimisation
The Keys Problem (Beyond "React Needs Keys")
Keys affect performance because they determine whether React reuses a component instance or creates a new one.
// ❌ Index as key — causes re-mounting on insert/delete
const items = ['Apple', 'Banana', 'Cherry']
items.map((item, index) => <Item key={index} name={item} />)
// Insert 'Avocado' at position 0:
// ['Avocado', 'Apple', 'Banana', 'Cherry']
// React sees key=0 is now 'Avocado' (was 'Apple')
// React sees key=1 is now 'Apple' (was 'Banana')
// React updates ALL items — even 'Apple' and 'Banana' which didn't change
// Also: any state inside Item (scroll position, form values) is lost
// ✓ Stable unique IDs as keys
items.map(item => <Item key={item.id} item={item} />)
// Inserting 'Avocado' with id=4:
// React sees keys 1, 2, 3 are the same — no update
// React only mounts the new key=4
Use index as key only when: the list is static (never reordered, never inserted into), or the items have no internal state.
Windowing for Large Lists
When rendering 1000+ items, use TanStack Virtual (or react-window). The concept from FE-00: render only visible items. The implementation:
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList({ items }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // estimated row height
overscan: 5, // extra rows above/below viewport
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
{/* Full-height inner div — makes scrollbar correct */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
)
}
When to use virtualisation: more than ~200 items in a list that the user scrolls. Under 200, normal rendering is fine. Over 200, you start seeing performance degradation on low-end devices.
Chapter 12: The Complete Diagnostic Workflow
Given a React performance complaint, run this procedure:
Step 1: Reproduce and Characterise
Define the complaint precisely:
- "The page feels slow to load" → likely a bundle/rendering strategy issue, not a re-render issue
- "Interactions feel laggy" → likely re-renders or long tasks
- "Scrolling stutters" → likely re-renders in a list, or a scroll event handler
- "A specific action is slow" → profile that specific action
Step 2: Open React DevTools Profiler
Record the problematic interaction. Look for:
- Components rendering when they shouldn't (grey = skipped is good)
- Render times (wide bars = slow)
- Re-render frequency (same component appearing many times in timeline = too many re-renders)
Step 3: Identify the Cause
For each slow or unnecessary re-render:
"Why did this component render?" (React DevTools Profiler tooltip):
- "Parent component rendered" → consider
memoif component is expensive - "Hook changed: useState" → is the state update necessary?
- "Hook changed: useContext" → context is updating too broadly, consider splitting
- "Props changed" → which prop? Is it an unstable reference?
Step 4: Apply the Correct Fix
| Cause | Fix |
|---|---|
| Parent re-render, component is expensive | React.memo |
| Props are unstable object references | useMemo to stabilise |
| Props are unstable function references | useCallback to stabilise |
| Context re-rendering too many consumers | Split context or use Zustand |
| Expensive in-render computation | useMemo |
| Fast input causing slow derived render | useTransition or useDeferredValue |
| 1000+ item list | Virtualisation |
| Component needs fresh state on prop change | key prop change |
Step 5: Verify in the Profiler
Profile again after the fix. Confirm the specific component no longer appears as a bottleneck. The profiler is the source of truth — not your intuition that the fix "should have worked."
Mental Models — Carry These Forever
1. Re-render ≠ DOM update. Re-rendering is cheap. DOM updates are expensive. React batches and minimises DOM updates. Unnecessary re-renders are usually only a problem when they are frequent AND the component tree is large.
2. Profile before optimising. Adding memo without profiling is premature optimisation. It adds overhead, cognitive noise, and often doesn't help.
3. memo is useless if props are unstable. If you pass an inline object or function, memo's comparison fails on every render. Fix the instability first with useMemo/useCallback.
4. Context is a broadcast. Every consumer re-renders on every context change. Use context for slow-changing data (theme, auth). Use Zustand/Jotai for frequently changing shared state.
5. useCallback and useMemo have costs. They are not free optimisations. They add comparison overhead and memory usage. Apply only where profiler evidence justifies it.
6. useTransition keeps inputs responsive. If a state update triggers expensive rendering, wrap it in startTransition. The UI stays responsive. The expensive render is interruptible.
7. key is an identity, not just a list helper. Changing key resets all state and remounts the component. Use this intentionally when you need a clean slate.
8. Virtualise lists over ~200 items. The DOM cannot handle thousands of nodes efficiently. Render only what is visible. The library handles the math.