React re-renders children by default when a parent updates, even if their props didn't change
React.memo stops re-renders by shallow-comparing props; useCallback and useMemo stabilise references for those comparisons
Lazy loading with React.lazy + Suspense splits the bundle, reducing initial download size
Virtualisation keeps DOM count constant for large lists, regardless of dataset size
Biggest mistake: wrapping everything in useMemo/useCallback without profiling first — the overhead often exceeds the gain
Plain-English First
Imagine a restaurant kitchen where every time one customer changes their order, the chef throws away every dish on the pass and starts cooking the entire menu again from scratch. That's what an un-optimised React app does — it re-renders every component even if nothing about it changed. React performance optimisation is the set of techniques that tell the chef: 'Table 4 only changed their dessert — leave the starters alone.' The goal isn't to make your code faster in theory; it's to stop React doing work it doesn't need to do.
React's declarative model is a gift — you describe what the UI should look like and React figures out how to get there. But that abstraction has a cost. In a large production app with hundreds of components, deeply nested state, and real-time data flowing in from WebSockets, React can easily end up doing tens of thousands of unnecessary renders per second. Users notice this as janky animations, sluggish inputs and frames dropping below 60fps. That's when 'React is fast enough' stops being true.
The root cause is almost always the same: developers treat re-renders as free. They're not. Every re-render means React has to call your component function, build a new virtual DOM tree, diff it against the previous one (reconciliation), and then commit any changes to the real DOM. Most of the time, the diff shows nothing changed — yet you still paid the cost of the function call and the tree construction. The optimisation techniques in this article all share a single goal: give React the information it needs to skip that work entirely.
By the end of this article you'll understand exactly how the React reconciler decides what to re-render and why, when to reach for React.memo, useMemo and useCallback (and critically, when NOT to), how to use code splitting and virtualisation to handle scale, and the production gotchas that bite even experienced engineers. Every example is pulled from patterns we've seen in real codebases.
How the React reconciler actually decides what to re-render
Before you optimise anything, you need a mental model of what React is actually doing. When state or props change, React re-renders the component that owns that state — and by default, every child of that component re-renders too, regardless of whether their own props changed. This is called a cascading re-render and it's the single biggest source of preventable work in a React app.
React's reconciler (the Fiber architecture since React 16) works in two phases. The render phase is pure and interruptible — React calls your component functions and builds a Fiber tree representing the new UI. The commit phase is synchronous and side-effectful — React applies DOM mutations, runs layout effects, then passive effects. Only DOM nodes that actually changed get touched in the commit phase. But you still paid the full render phase cost for every component in the subtree.
The key insight is this: React uses referential equality (===) to decide whether props changed. A plain object literal {} created inside a parent component is a new reference every render, even if its contents are identical. That's why memoisation isn't just about expensive calculations — it's primarily about reference stability. Unstable references are the root cause of most unnecessary re-renders.
// Type 'key' into the search input — watch the console:
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
// ProductCard re-rendered 3 times for state it doesn't own or care about
Watch Out:
Install the React DevTools browser extension and enable 'Highlight updates when components render'. Run it on your production app before reaching for any optimisation API. You'll almost certainly find components re-rendering 10x more than you expected — but now you'll know exactly where to look.
Production Insight
In a production dashboard, a single state update can cascade through hundreds of components.
If you don't use React.memo, every child re-renders — even leaf components with stable props.
Rule: Profile first, then React.memo the leaves, stabilise the props.
Key Takeaway
React re-renders children by default.
The reconciler's render phase costs you time even if the commit phase skips DOM updates.
Stabilise references with useMemo/useCallback to let React.memo work.
React.memo, useMemo and useCallback — what each one actually does
These three APIs are frequently used interchangeably by developers who've half-read the docs. They solve different problems and conflating them leads to both under-optimised and over-engineered code.
React.memo is a higher-order component that wraps a component and tells React: 'Only re-render this component if its props have changed.' It does a shallow equality check on the props object. If every prop passes ===, React reuses the last rendered output entirely — it doesn't even call your component function.
useCallback memoises a function reference. It returns the same function object across renders as long as its dependency array hasn't changed. Its primary job is to create stable references to pass as props to memoised children — without it, React.memo is nearly useless because a new function reference counts as a changed prop.
useMemo memoises the return value of a computation. Use it for expensive calculations that shouldn't run on every render, or to stabilise object and array references that get passed as props. The dependency array works identically to useCallback — the memoised value is only recomputed when a dependency changes.
The rule of thumb: if you're passing a callback to a memoised child, use useCallback. If you're passing a derived object or doing expensive maths, use useMemo. Don't use either just because you can.
// USB-C Hub and Monitor Arm did NOT re-render — React.memo worked
// The featured ProductCard at the top also did NOT re-render
Interview Gold:
Interviewers love asking 'when would you NOT use useMemo?' The honest answer: most of the time. Memoisation has overhead — it allocates memory for the cache, runs dependency comparisons, and adds cognitive load. For cheap calculations (string concatenation, simple arithmetic), the memoisation cost often exceeds the savings. Only reach for it when React DevTools shows a genuine problem.
Production Insight
A common production scare: you add useCallback on every callback, and React.memo on every component, but performance barely improves.
That's because the parent still re-renders all child instances — memo only skips the render phase if props are stable.
Rule: Apply memo only where props are actually stable; otherwise the comparison overhead is wasted.
Key Takeaway
React.memo skips render phase for unchanged props.
useCallback and useMemo stabilise references for that shallow comparison.
Don't memoise everything — profile first, then apply surgically.
Code splitting with React.lazy, Suspense and dynamic imports
Memoisation fights unnecessary re-renders. Code splitting fights the other performance killer: loading too much JavaScript upfront. In a typical React SPA, bundlers like Webpack or Vite produce a single JavaScript bundle. A user visiting your landing page downloads code for your admin dashboard, your analytics charts, and every other route — most of which they'll never touch. This bloats the initial bundle, delays Time to Interactive, and kills Lighthouse scores.
Code splitting lets you split your bundle into smaller chunks that load on demand. React.lazy and Suspense are the built-in APIs for this. React.lazy takes a function that calls a dynamic import() — a browser-native API that returns a Promise resolving to a module. React defers rendering that component until the module has loaded, and Suspense defines what to show in the meantime.
The sweet spot for lazy loading is route-level splitting — each page becomes its own chunk. But it's also valuable for heavy third-party components like rich text editors, PDF viewers, or chart libraries that aren't needed on initial load. The rule: if a user's critical path doesn't need it within the first three seconds, consider lazy loading it.
A critical gotcha: React.lazy only works with default exports. Named exports require a small wrapper. Also, always wrap lazy components high enough in the tree that the Suspense fallback doesn't cause layout shift.
// Browser Network tab when user clicks 'Analytics' for the first time:
// GET /assets/AnalyticsDashboard-Bx3kP2qR.js 180 kB (downloaded once, cached after)
// Skeleton renders immediately, then dashboard appears when chunk is ready
// Subsequent clicks on 'Analytics':
// No network request — chunk is already in browser cache
// Dashboard renders immediately (no Suspense fallback shown)
Pro Tip:
Use prefetching to eliminate the loading delay for predictable navigation. Add a mouseenter handler on nav links that calls import('./pages/AnalyticsDashboard') — this preloads the chunk while the user is still moving their cursor. By the time they click, the chunk is already cached and Suspense resolves instantly. Vite also supports / @vite-prefetch / and / webpackPrefetch: true / magic comments for automatic prefetching.
Production Insight
A team once lazy-loaded a chart component but didn't provide a fallback with matching dimensions.
The result: Layout shift on every navigation to the analytics page, causing Cumulative Layout Shift (CLS) spikes.
Rule: Always match the fallback's dimensions to the lazy component's expected size to prevent CLS.
Key Takeaway
React.lazy + Suspense reduces initial bundle by deferring heavy modules.
Route-level splitting is the sweet spot; also lazy load heavy third-party components.
Prevent layout shift by dimension-matching the Suspense fallback.
Virtualisation for long lists — only render what the user can see
No amount of memoisation saves you if you're trying to render 10,000 DOM nodes at once. The browser has to create, style, and layout each one. A list with 5,000 items might take 2–3 seconds just to mount — and that's before any interaction.
Virtualisation (also called windowing) solves this by only rendering the DOM nodes currently visible in the viewport, plus a small overscan buffer above and below. As the user scrolls, nodes are recycled — elements that scroll off the top are repositioned and reused for content coming in from the bottom. The DOM stays small (typically 20–50 nodes) regardless of how many items are in the data set.
@tanstack/react-virtual is the modern, framework-agnostic choice. It's headless — it gives you the calculations, you control the markup. react-window and react-virtualized are older but still widely used in production and worth knowing for legacy codebases.
Critical consideration: virtualisation breaks native browser 'find in page' (Ctrl+F) because non-rendered items aren't in the DOM. For accessibility and search-critical content, consider server-side pagination instead. Also, fixed-height rows are significantly simpler to implement than variable-height rows — @tanstack/react-virtual supports both, but dynamic measurement has its own complexity.
// DOM inspection in DevTools shows only ~16 ProductRow divs exist at any time
// Page mount time: ~12ms (vs ~2400ms without virtualisation)
// Memory usage: ~8 MB (vs ~180 MB without virtualisation)
Watch Out:
Never virtualise a list of fewer than ~100 items. The position:absolute layout removes items from normal document flow, which breaks things like CSS grid siblings, sticky headers within the list, and native Ctrl+F search. The overhead of setting up a virtualiser on a 50-item list is pure cost with no measurable benefit.
Production Insight
A product list page with 8,000 items was using virtually no optimisation; mount time was 2.4 seconds.
After virtualisation, mount time dropped to 12ms — same data, 200x faster.
But the team forgot to add a meaningful loading state for the overscan, causing visible whitespace during fast scroll.
Rule: set overscan high enough (10-15) to mask fetch latency if items are loaded async.
Key Takeaway
Virtualisation keeps DOM count constant regardless of list size.
Use @tanstack/react-virtual for modern apps, react-window for legacy.
Watch for Ctrl+F breakage and dimension mismatches; fixed-height rows are simplest.
Profiling React Performance: The One Tool You Must Know
All optimisation without profiling is guesswork. The React DevTools Profiler is your single source of truth. It records each render commit, shows which components re-rendered and why, and gives you the flamegraph of where time is spent. Without it, you're flying blind.
To use it: record an interaction (e.g., typing in a search input, clicking a button). The profiler shows each commit as a bar at the top. Click a commit to see a flamegraph. Components that re-rendered are highlighted; their color intensity corresponds to time spent. Hover over a component to see 'why did this render?' — often it's 'Parent re-rendered' or 'Props changed' with the specific prop.
Also enable the 'Highlight updates when components render' option in the React DevTools settings. It overlays colored borders on components that re-render. If you see a border flashing on a component that shouldn't re-render, you've found the leak.
A production-safe pattern: add a debug-only render counter using React.useRef in components. Log out render counts to the console in development. This catches cascading re-renders early without relying on the profiler every time.
useRenderCounter.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useRef, useEffect } from'react';
// A custom hook to track how many times a component renders (development only)exportfunctionuseRenderCounter(componentName = 'Component') {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
if (process.env.NODE_ENV === 'development') {
console.log(`[Render ${componentName}] #${renderCount.current}`);
}
});
return renderCount.current;
}
// Usage inside a component:// const renderCount = useRenderCounter('ProductCard');// You can use renderCount to display it on the UI if needed.
Output
// With the hook added to ProductCard, typing in the search input outputs:
[Render ProductCard] #1
[Render ProductCard] #2
[Render ProductCard] #3
[Render ProductCard] #4
// Immediately shows that ProductCard re-renders unnecessarily 3 times per keystroke.
// After adding React.memo and useCallback, only #1 appears on initial mount.
Pro Tip:
Don't ship render count logs to production. Use process.env.NODE_ENV guards (most bundlers strip dead code). In CRA, environment variables prefixed with REACT_APP_ are available; use REACT_APP_ENABLE_PROFILING for production-safe profiling flags.
Production Insight
A team was deploying unnecessary re-renders to production because they only tested on personal machines with DevTools open.
They didn't realise the profiler itself adds overhead and masks issues in production.
Rule: Always test performance on production builds with production mode, and use browser DevTools Performance tab (not React Profiler) for final validation.
Key Takeaway
Profile with React DevTools Profiler before any optimisation.
Look for components re-rendering due to 'Parent re-rendered' — those are your targets.
Use a debug render counter in development to catch cascading re-renders early.
When NOT to optimise: the hidden cost of premature optimisation
The React team's own documentation warns against premature memoisation. Here's why: every useMemo and useCallback call allocates memory for a cache entry, runs dependency comparison on every render, and increases the cognitive load of the code. For a simple calculation (e.g., const fullName = ${first} ${last}``), wrapping it in useMemo adds more overhead than the calculation itself.
Similarly, React.memo on a component that re-renders frequently with actually different props is wasted — the comparison runs every render and never skips the re-render anyway. It's pure overhead.
The rule: measure before optimising. Use the profiler to find components that re-render more than expected or take >1ms in the render phase. Apply memoisation only to those. Also, consider restructuring your component tree before reaching for optimisation APIs. Lifting state up or pushing it down often solves the problem without adding any memo calls.
Finally, remember that React 19's compiler (React Forget) aims to auto-memoise components. But that doesn't mean you can ignore these fundamentals today. The compiler understands reference stability and dependencies; you'll still need to write clean component trees.
AntiPattern.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
importReact, { useMemo, useCallback } from'react';
// Bad: useMemo on a trivial string concatenationfunctionUserGreeting({ firstName, lastName }) {
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
return <h1>Hello, {fullName}</h1>;
}
// The concatenation is O(1), the useMemo adds allocation and dep check.// Just write: const fullName = `${firstName} ${lastName}`;// Also bad: useCallback on a handler passed to a non-memoised childfunctionParent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// Child is a plain div, not React.memo. useCallback does nothing useful.return <div onClick={handleClick}>Click me</div>;
}
Output
// In React DevTools Profiler, these components show no render time difference.
// The useMemo/useCallback overhead is visible as additional function calls in flamegraph.
// Removing them has zero negative impact and simplifies the code.
Interview Gold:
A great answer to 'when not to use useMemo' is: when the computation is cheap (string concat, simple arithmetic), when the component re-renders with different props every time (memoisation never skips), and when you haven't profiled. The best optimisation is often restructuring — moving state down, splitting components, or using keys properly.
Production Insight
A team wrapped all computations in useMemo and all callbacks in useCallback as a 'best practice'.
They had a form with 50+ fields, each with useCallback, making the code 3x longer.
Performance didn't improve because the form re-rendered on every keystroke anyway — the overhead of 50 useCallback deps checks added measurable latency.
Rule: Only memoise if the profiler shows a specific component is a bottleneck.
Key Takeaway
Premature memoisation is an antipattern.
Measure with the profiler first, then optimise only the bottlenecks.
Restructuring the component tree is often more effective than adding memoisation hooks.
● Production incidentPOST-MORTEMseverity: high
The Dashboard That Froze for 3 Seconds on Every Keystroke
Symptom
Typing in a search input caused the entire dashboard (20+ charts, tables, and widgets) to freeze for 2-3 seconds. Console showed React was calling every component function on every keystroke, even those that didn't depend on the search query.
Assumption
The assumption was that React was efficient enough out of the box; if a component's props don't change, React won't re-render it.
Root cause
No memoisation on any component. The search state was kept in a top-level context, and every child re-rendered due to cascading render. Additionally, inline object literals and arrow functions in JSX created new references every render, making React.memo useless even if it had been applied.
Fix
Applied React.memo to stable chart components, wrapped expensive callbacks in useCallback, and stabilised data objects with useMemo. Also moved the search-specific state down into a dedicated component to isolate the re-render scope. The freeze dropped from 3 seconds to under 200ms.
Key lesson
Always profile before optimising — React DevTools Profiler shows exactly which components re-render and why.
Stabilise references of props passed to memoised children: useMemo for objects/arrays, useCallback for functions.
Move state as close as possible to where it's used to limit re-render scope.
Production debug guideSymptom → Action guide for identifying and fixing React performance issues4 entries
Symptom · 01
Component re-renders even though its props look the same
→
Fix
Open React DevTools > Profiler > Highlight updates when components render. Check the component's props in the 'Change' column — look for new object/array/function references on each render.
Symptom · 02
useMemo or useCallback seems to have no effect
→
Fix
Verify the dependency array. If you omitted a dependency, the memoised value is stale. If you included a new object reference each render, memoisation is useless. Use eslint-plugin-react-hooks/exhaustive-deps to catch both.
Symptom · 03
React.memo component still re-renders when parent re-renders
→
Fix
Check every prop for referential instability. Inline objects ({ theme: 'dark' }) create new references. Move static values outside the component, or wrap dynamic ones in useMemo. Also check that you aren't passing a new function reference (useCallback needed).
Symptom · 04
Large list re-renders on every state update, causing frame drops
→
Fix
If the list has 100+ items, apply virtualisation (@tanstack/react-virtual) to only render visible rows. For smaller lists, use React.memo on list item components and stabilise their props.
★ Quick React Performance Debugging Cheat SheetCommands and actions to diagnose and fix performance issues fast.
Janky UI on state updates−
Immediate action
Open browser DevTools > Performance tab, record interaction, look for long 'Function Call' or 'Rendering' blocks.
Commands
React DevTools Profiler -> Record -> Trigger the interaction -> Inspect 'Commits' > 'What caused this update'
Add console.log('%c[RENDER] ComponentName', 'color:blue') in every component to see which ones re-render on each action.
Fix now
Use React.memo on leaf components, stabilise props with useMemo/useCallback, and avoid creating new objects/functions in render.
Slow initial page load+
Immediate action
Check Network tab — if a single JS bundle is > 500KB, it's a code splitting candidate.
Commands
In Chrome DevTools: Run Lighthouse > Performance > Metrics > Total Blocking Time
Run `npx source-map-explorer build/static/js/main.*.js` to see what's in your bundle.
Fix now
Add React.lazy on route-level components and heavy third-party libs (chart libs, PDF viewers) that aren't needed above the fold.
Memory grows over time, page becomes sluggish+
Immediate action
Take heap snapshot in Chrome DevTools > Memory, compare two snapshots after interaction.
Commands
In React DevTools Profiler, enable 'Record why each component rendered' and look for 'Parent re-rendered' cause.
Check for missing cleanup in useEffect: return a cleanup function that removes event listeners, subscriptions, or aborts fetch requests.
Fix now
Ensure all effects have cleanup functions, avoid storing large objects in state that aren't needed, and use WeakMap for caches if applicable.
React Performance Techniques Comparison
Technique
What it prevents
When to use
Hidden cost
React.memo
Unnecessary re-renders when props haven't changed
Memoised children receiving stable props from a frequently-re-rendering parent
Shallow props comparison on every render — wasted if props change often
useMemo
Expensive recalculations and unstable object/array references
Computationally heavy derivations, or objects/arrays passed as props to memoised children
Memory allocation for the cache, dependency array comparison, cognitive overhead
useCallback
New function references causing memoised children to re-render
Callbacks passed as props to React.memo'd components or as dependencies in other hooks
Same memory overhead as useMemo — pointless without React.memo on the receiving component
React.lazy + Suspense
Oversized initial JS bundle delaying Time to Interactive
Route-level splits, heavy optional features (charts, editors, PDF viewers)
Extra network request on first use — mitigate with prefetching
Virtualisation
Rendering thousands of DOM nodes at once causing slow mount and scroll
Lists with 100+ dynamic items where pagination isn't viable
React re-renders children by default whenever a parent re-renders
React.memo opts a component out of this by caching its output and only re-rendering when props change via shallow equality (===).
2
useCallback and useMemo are reference-stabilisation tools first, and computation-savers second
their most important job is preventing new object/function references from invalidating React.memo.
3
Code splitting with React.lazy + dynamic import() reduces initial bundle size at the route or feature level
combine it with mouseenter prefetching to eliminate visible loading delays on predictable navigation.
4
Virtualisation with @tanstack/react-virtual keeps the DOM node count constant regardless of dataset size
mount time and memory usage stay flat whether your list has 100 or 100,000 items.
5
Profile before optimising. Premature memoisation adds overhead and complexity. Use React DevTools Profiler to find real bottlenecks, then apply optimisations surgically.
6
Restructuring component state (lifting or lowering state) often solves performance issues better than any memoisation hook.
Common mistakes to avoid
5 patterns
×
Defining objects or arrays inline inside JSX props on a memoised component
Symptom
React.memo never skips a re-render because every render creates a new reference for the inline object/array. The shallow comparison always sees a change.
Fix
Move static objects outside the component. For dynamic ones, wrap them in useMemo so the reference stays the same across renders unless the underlying data changes.
×
Using useCallback with a stale closure by omitting state variables from the dependency array
Symptom
The callback always operates on the initial value of the omitted state variable. Updates to that variable are never seen, causing hard-to-debug bugs where clicks or events trigger stale data.
Fix
Either include all referenced state in the dependency array, or use the functional updater form (setState(prev => prev + 1)) which doesn't need to close over the current value.
×
Wrapping everything in useMemo and useCallback 'just in case'
Symptom
No measurable performance improvement, but code is harder to read and refactor. The overhead of dependency comparisons and memory allocation may actually degrade performance in hot paths.
Fix
Profile with React DevTools Profiler first, identify components with high render times or render counts, then apply memoisation surgically where the measurement shows a real gain.
×
Forgetting to add a key or using index as key when rendering lists
Symptom
When the list order changes, React can't match components to previous instances, leading to unnecessary re-renders and potential state loss in controlled inputs.
Fix
Always use a stable, unique identifier as the key (e.g., database ID). Avoid using index unless the list is static and never reordered.
×
Lazy loading components that are needed above the fold
Symptom
The initial page load is slower because the lazy component's chunk needs to be fetched before rendering, causing a visible flash or empty state for critical UI.
Fix
Only lazy load components that are not in the critical rendering path. Use route-level splitting for pages below the fold, or heavy third-party components that can appear after initial paint. For above-fold components, consider direct imports with proper code splitting via other mechanisms (e.g., React Server Components).
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
If React.memo does a shallow comparison of props, how would you handle a...
Q02SENIOR
Explain the relationship between useCallback and React.memo. Can React.m...
Q03SENIOR
A colleague says useMemo is always a good idea because 'it can only help...
Q04SENIOR
How would you debug a situation where a React app feels janky but you ca...
Q01 of 04SENIOR
If React.memo does a shallow comparison of props, how would you handle a component that receives a deeply nested object as a prop where only a nested field changes — and why is the standard advice 'flatten your state' rather than 'use a custom comparator'?
ANSWER
Shallow comparison won't detect deep changes. A custom comparator (second argument to React.memo) can perform deep comparison, but that's expensive and usually a code smell. Flattening state means splitting the deeply nested object into separate props or using multiple smaller components so that only the relevant part triggers a re-render. For example, instead of passing a user object with 20 fields, pass name, email, role as separate props. That way, React.memo compares each primitive directly, and only the display component for the changed field re-renders.
In practice, if flattening isn't feasible, consider using useMemo on the parent to return a stable reference to the deeply nested object, and only update that reference when the relevant nested field changes. But that often requires manual dependency tracking and is error-prone. The cleaner path is refactoring the component hierarchy.
Q02 of 04SENIOR
Explain the relationship between useCallback and React.memo. Can React.memo ever skip a re-render if you're not using useCallback for the callbacks you pass to it? Walk me through why.
ANSWER
React.memo does a shallow props comparison. If you pass a callback defined inline (e.g., onClick={() => doSomething()}), that's a new function reference on every render of the parent. React.memo's shallow comparison will see the onClick prop changed (new reference) and will re-render the child, even if the child's other props are stable.
useCallback returns the same function reference across renders as long as its dependencies haven't changed. When you pass that stable reference as a prop to a React.memo'd component, the shallow comparison sees the same reference and skips the re-render.
So yes, React.memo can skip a re-render without useCallback, but only if every prop is a primitive or a stable reference. If any prop is an inline function, object, or array, memoisation is defeated. In practice, callbacks are almost always unstable unless wrapped in useCallback, so useCallback is necessary for React.memo to be effective when callbacks are involved.
Q03 of 04SENIOR
A colleague says useMemo is always a good idea because 'it can only help, never hurt'. What would you tell them — and what specific scenario would you use to prove them wrong?
ANSWER
I'd strongly disagree. useMemo adds overhead: it allocates a cache entry, runs dependency comparison on every render, and increases memory pressure. For cheap computations (string concatenation, simple arithmetic, boolean expressions), the cost of memoisation often exceeds the cost of recomputation.
A concrete scenario: a UserAvatar component renders a simple greeting: 'Hello ' + firstName + ' ' + lastName. That's a cheap string operation. If you wrap it in useMemo, every render you pay for:
- Creating the callback (or the useMemo call itself)
- Comparing the dependency array (two strings)
- Possibly storing the result in memory until dependencies change
In a list of 1000 such components, adding useMemo could degrade performance by adding thousands of extra function calls per render cycle. The React DevTools Profiler will show that the useMemo version takes longer to render than the plain version.
The correct approach: only use useMemo when the computation is expensive (e.g., filtering a large array, deep object transformations, complex math) OR when you need to stabilise a reference for a memoised child. Otherwise, leave it out.
Q04 of 04SENIOR
How would you debug a situation where a React app feels janky but you can't identify which component is causing the issue?
ANSWER
1. Open React DevTools Profiler and record a typical interaction. Look for commits that take >16ms (which would cause frame drops at 60fps). Click on those commits and inspect the flamegraph.
2. Also enable 'Highlight updates when components render' in DevTools settings to visually see re-renders.
3. If the profiler shows a 'Parent re-rendered' cause, focus on that parent — it's re-rendering children unnecessarily. Check its state updates and see if you can isolate state.
4. If you can't find the culprit, add a custom render counter hook (like useRenderCounter) to key components to see how many times they render.
5. For production profiling, use the browser's Performance tab (Chrome DevTools) to record a session and look for long 'Function Call' or 'Rendering' events. Often these are from large component subtrees or frequent state updates.
6. Check for missing React.memo on high-frequency state updates (e.g., input change handlers).
7. If the jank is consistent, inspect the component tree for expensive effects or computations running synchronously. Move them to useMemo or to useEffect with proper dependencies.
01
If React.memo does a shallow comparison of props, how would you handle a component that receives a deeply nested object as a prop where only a nested field changes — and why is the standard advice 'flatten your state' rather than 'use a custom comparator'?
SENIOR
02
Explain the relationship between useCallback and React.memo. Can React.memo ever skip a re-render if you're not using useCallback for the callbacks you pass to it? Walk me through why.
SENIOR
03
A colleague says useMemo is always a good idea because 'it can only help, never hurt'. What would you tell them — and what specific scenario would you use to prove them wrong?
SENIOR
04
How would you debug a situation where a React app feels janky but you can't identify which component is causing the issue?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Does React.memo do a deep comparison of props?
No — React.memo performs a shallow (===) comparison by default. For primitive props like strings and numbers this is effectively a value comparison. For objects and arrays it compares references, so a new object literal with identical contents still counts as 'changed'. You can pass a custom comparison function as the second argument to React.memo for deep comparison, but this is rarely the right solution — flattening props or stabilising references with useMemo is almost always cleaner.
Was this helpful?
02
When should I use useCallback vs useMemo?
Use useCallback when you need a stable function reference — typically a callback you're passing as a prop to a React.memo'd child or using as a dependency in another hook. Use useMemo when you need a stable non-function value — an expensive calculation result, a derived object, or an array that shouldn't change reference between renders. Internally they're the same mechanism; useCallback(fn, deps) is literally equivalent to useMemo(() => fn, deps).
Was this helpful?
03
Does splitting code with React.lazy hurt SEO?
It can if you're relying on client-side rendering. A lazy-loaded component that hasn't rendered yet has no HTML for search engine crawlers to index. The solution is server-side rendering (Next.js handles this transparently — lazy loaded components are still rendered on the server). For pure CSR apps, avoid lazy loading content that needs to be indexed, and use it primarily for behind-authentication pages and heavy interactive features.
Was this helpful?
04
Can virtualised lists be accessible?
Yes, but with caveats. Virtualised lists break native Ctrl+F find-in-page because non-rendered items aren't in the DOM. For accessibility, ensure you use ARIA roles (list, listitem), manage focus correctly when scrolling, and provide a meaningful number of items to screen readers. Libraries like @tanstack/react-virtual support tabIndex management and focus restoration. Also consider providing a fallback non-virtualised view for users with certain assistive technologies.
Was this helpful?
05
What's the difference between the React Profiler and the browser Performance tab?
The React DevTools Profiler shows you component-level render times and the 'why' behind each re-render (props changed, state changed, etc.). It's great for identifying which component is wasting time and why. The browser Performance tab (Chrome) shows you lower-level operations: function calls, layout, paint, scripting. Use the React Profiler to find the components, then use the browser Performance tab to understand the overall frame budget and confirm that your changes actually reduce frame times.