reference on every parent render
useEffect runs triggered by unnecessary dependency changes
Step 2: Implement Boundary Memoization
Memoize only values that cross component boundaries or serve as dependency array entries. Internal computations that do not affect child props should remain unmemorized.
import { useState, useMemo, useCallback, useRef, useSyncExternalStore } from 'react';
interface FilterOptions {
status: 'active' | 'inactive' | 'all';
search: string;
}
interface UseOptimizedListOptions<T> {
initialData: T[];
filterOptions: FilterOptions;
pageSize: number;
}
// External store simulation for real-time data
const createExternalStore = <T,>(initial: T[]) => {
let listeners = new Set<() => void>();
let data = initial;
return {
subscribe: (l: () => void) => { listeners.add(l); return () => listeners.delete(l); },
getSnapshot: () => data,
update: (next: T[]) => { data = next; listeners.forEach(l => l()); },
};
};
const store = createExternalStore<any[]>([]);
export function useOptimizedList<T extends { id: string }>({
initialData,
filterOptions,
pageSize,
}: UseOptimizedListOptions<T>) {
const [page, setPage] = useState(0);
// 1. Use useSyncExternalStore for external subscriptions (React 18+)
const externalData = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
() => initialData
);
// 2. Memoize expensive computations only when dependencies change
const filteredData = useMemo(() => {
return externalData.filter(item => {
const matchesStatus = filterOptions.status === 'all' || item.status === filterOptions.status;
const matchesSearch = filterOptions.search
? JSON.stringify(item).toLowerCase().includes(filterOptions.search.toLowerCase())
: true;
return matchesStatus && matchesSearch;
});
}, [externalData, filterOptions.status, filterOptions.search]);
// 3. Stable pagination reference
const paginatedData = useMemo(
() => filteredData.slice(page * pageSize, (page + 1) * pageSize),
[filteredData, page, pageSize]
);
// 4. Callbacks that cross boundaries must be memoized
const handlePageChange = useCallback((nextPage: number) => {
setPage(prev => Math.max(0, Math.min(nextPage, Math.ceil(filteredData.length / pageSize) - 1)));
}, [filteredData.length, pageSize]);
// 5. Use ref for values that change frequently but don't trigger renders
const scrollRef = useRef<HTMLDivElement>(null);
const isScrollingRef = useRef(false);
// 6. Return stable shape to prevent child re-renders
return useMemo(
() => ({
data: paginatedData,
totalCount: filteredData.length,
page,
setPage: handlePageChange,
scrollRef,
isScrollingRef,
}),
[paginatedData, filteredData.length, page, handlePageChange]
);
}
Step 3: Apply Component-Level Memoization
Wrap consumer components with React.memo only when props are guaranteed stable.
import { memo } from 'react';
const DataTable = memo(({ data, onPageChange }: { data: any[]; onPageChange: (n: number) => void }) => {
return (
<div>
{data.map(row => <TableRow key={row.id} row={row} />)}
<button onClick={() => onPageChange(1)}>Next</button>
</div>
);
});
Architecture Decisions & Rationale
- Memoize at boundaries, not inside: Internal state transformations are cheap. Memoization cost (dependency comparison, cache storage) outweighs benefits when applied to stable values or internal-only data.
- Dependency arrays as contracts: Treat
useMemo/useCallback dependency arrays as explicit contracts. Missing dependencies cause stale closures; extra dependencies cause unnecessary recomputation.
useRef for mutable non-render state: Scroll position, animation frames, and interval IDs should use useRef to avoid triggering renders while remaining accessible in closures.
useSyncExternalStore for external subscriptions: Replaces useEffect + setState patterns for external data, guaranteeing consistent snapshots during concurrent renders and preventing tearing.
- Stable return shapes: Custom hooks should return a single memoized object or array. Returning multiple independent values forces consumers to destructure unstable references.
Pitfall Guide
1. Memoizing Everything
Wrapping every function and value in useCallback/useMemo increases JavaScript execution time by 15-25% due to dependency tracking and cache management. Memoization is only beneficial when the computed value crosses a React.memo boundary or serves as a dependency for another hook.
2. Incomplete Dependency Arrays
Omitting state or props from dependency arrays creates stale closures. React's eslint-plugin-react-hooks catches most cases, but developers often disable the rule or use // eslint-disable-next-line without understanding the consequence. Stale closures cause bugs that manifest hours or days after deployment, making them expensive to debug.
3. Memoizing Stable Values
Functions defined outside components, primitive literals, and static configurations do not need memoization. useCallback(() => {}, []) on a function that never closes over changing state adds overhead without preventing re-renders. The reference is already stable.
4. Ignoring Child Component Render Cost
Memoizing a parent hook while leaving child components unoptimized creates false performance gains. If a child receives a new prop reference on every render, React.memo on the parent does nothing. Optimization must follow the render tree: stabilize props at the boundary, then apply React.memo to the consumer.
5. useEffect Cleanup Leaks
Missing cleanup functions in useEffect cause memory leaks and duplicate event listeners. When dependency arrays change frequently, uncleaned effects accumulate. Always return a cleanup function, even if it's a no-op, to enforce discipline.
6. Measuring Without Profiling
Optimizing based on intuition or bundle size metrics misses render-time bottlenecks. Chrome DevTools Performance tab and React Profiler measure actual execution cost. Without profiling, teams optimize code paths that execute in <0.1ms while ignoring 20ms re-render spikes.
7. Over-Optimizing Synchronous vs Concurrent Updates
React 18 batches state updates automatically. Wrapping synchronous state changes in useTransition or useDeferredValue without measuring actual blocking time introduces unnecessary complexity. Use concurrent features only when profiling shows main-thread blocking >100ms during user interactions.
Production Best Practices:
- Profile first, optimize second. Never add memoization without a recorded baseline.
- Treat dependency arrays as explicit contracts. Run
eslint-plugin-react-hooks in CI.
- Memoize at component boundaries. Internal computations should remain unmemorized unless they exceed 5ms execution time.
- Use
useRef for mutable state that doesn't affect UI.
- Return stable shapes from custom hooks. Destructuring unstable references breaks memoization downstream.
- Validate performance gains with Web Vitals and React Profiler after each optimization pass.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| List rendering >500 items | useMemo for filtered data + React.memo for row components | Prevents O(n) re-renders on filter change | High gain, low memory cost |
| Form input with real-time validation | useCallback for submit handler only; keep input state unmemorized | Validation runs on every keystroke; memoizing input state causes lag | Medium gain, prevents input jank |
| WebSocket/external data stream | useSyncExternalStore with stable snapshot selector | Guarantees consistent state during concurrent renders | High gain, eliminates tearing |
| Animation loop (60Hz) | useRef for mutable frame state + requestAnimationFrame | State updates trigger renders; refs avoid reconciliation | High gain, maintains frame rate |
| Static configuration object | No memoization; define outside component or use const | Reference is already stable; memoization adds overhead | Neutral to negative if misapplied |
Configuration Template
ESLint Configuration (.eslintrc.cjs)
module.exports = {
extends: ['plugin:react-hooks/recommended'],
rules: {
'react-hooks/exhaustive-deps': 'error',
'react-hooks/rules-of-hooks': 'error',
},
};
React Profiler Setup (src/profiler.ts)
import { Profiler } from 'react';
const onRenderCallback = (
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
if (process.env.NODE_ENV === 'production') {
console.group(`[Profiler] ${id} (${phase})`);
console.log(`Actual: ${actualDuration.toFixed(2)}ms | Base: ${baseDuration.toFixed(2)}ms`);
console.log(`Start: ${startTime.toFixed(2)}ms | Commit: ${commitTime.toFixed(2)}ms`);
console.groupEnd();
}
};
export const PerformanceProfiler = ({ children }: { children: React.ReactNode }) => (
<Profiler id="app-root" onRender={onRenderCallback}>
{children}
</Profiler>
);
Custom Hook Template (src/hooks/useOptimizedState.ts)
import { useState, useMemo, useCallback, useRef } from 'react';
export function useOptimizedState<T>(initial: T) {
const [state, setState] = useState<T>(initial);
const mutableRef = useRef<T>(initial);
const stableState = useMemo(() => state, [state]);
const setStableState = useCallback((next: T | ((prev: T) => T)) => {
setState(prev => {
const resolved = typeof next === 'function' ? (next as (p: T) => T)(prev) : next;
mutableRef.current = resolved;
return resolved;
});
}, []);
return useMemo(
() => ({ state: stableState, setState: setStableState, mutableRef }),
[stableState, setStableState]
);
}
Quick Start Guide
- Install profiling tooling: Add
eslint-plugin-react-hooks and wrap your app root with PerformanceProfiler. Run npm run build and open Chrome DevTools > Performance tab.
- Record baseline interaction: Click "Record", perform your target user action (scroll, submit, filter), stop recording. Note JS execution time and render count for the affected component tree.
- Identify unstable references: In React DevTools Profiler, expand the flamegraph. Look for components with high "render count" but low "actual time". Check which props/callbacks change reference on every render.
- Apply boundary memoization: Wrap cross-boundary values with
useMemo/useCallback. Ensure dependency arrays match the ESLint recommendations. Return a single memoized shape from custom hooks.
- Validate and iterate: Re-run the profiler. Confirm JS execution time drops without memory regression. If gains are <5%, remove memoization and investigate other bottlenecks (e.g., layout thrashing, network latency).
Hook performance optimization is a discipline of reference stability, not blanket memoization. Profile rigorously, memoize at boundaries, and let the data dictate the implementation.