Problem Statement
React performance issues often stem from unnecessary re-renders, unoptimized lists, and expensive computations on the main thread. Understanding React's rendering behavior is key to building performant applications.
Pattern: Memoization
useMemo - Expensive Computations
typescript
1// ✅ CORRECT: Memoize expensive calculation
2const sortedAndFilteredItems = useMemo(() => {
3 return items
4 .filter(item => item.active)
5 .sort((a, b) => b.score - a.score)
6 .slice(0, 100);
7}, [items]);
8
9// ❌ WRONG: Recalculates every render
10const sortedAndFilteredItems = items
11 .filter(item => item.active)
12 .sort((a, b) => b.score - a.score);
13
14// ❌ WRONG: Memoizing simple access (overhead > benefit)
15const userName = useMemo(() => user.name, [user.name]);
When to use useMemo:
- Array transformations (filter, sort, map chains)
- Object creation passed to memoized children
- Computations with O(n) or higher complexity
useCallback - Stable Function References
typescript
1// ✅ CORRECT: Stable callback for child props
2const handleClick = useCallback((id: string) => {
3 setSelectedId(id);
4}, []);
5
6// Pass to memoized child
7<MemoizedItem onClick={handleClick} />
8
9// ❌ WRONG: useCallback with unstable deps
10const handleClick = useCallback((id: string) => {
11 doSomething(unstableObject); // unstableObject changes every render
12}, [unstableObject]); // Defeats the purpose
When to use useCallback:
- Callbacks passed to memoized children
- Callbacks in dependency arrays
- Event handlers that would cause child re-renders
Pattern: React.memo
typescript
1// Wrap components that receive stable props
2const ItemCard = memo(function ItemCard({
3 item,
4 onSelect
5}: Props) {
6 return (
7 <div onClick={() => onSelect(item.id)}>
8 <h3>{item.name}</h3>
9 <p>{item.price}</p>
10 </div>
11 );
12});
13
14// Custom comparison for complex props
15const ItemCard = memo(
16 function ItemCard({ item, onSelect }: Props) {
17 // ...
18 },
19 (prevProps, nextProps) => {
20 // Return true if props are equal (skip re-render)
21 return (
22 prevProps.item.id === nextProps.item.id &&
23 prevProps.item.price === nextProps.item.price
24 );
25 }
26);
When to use React.memo:
- List item components
- Components receiving stable primitive props
- Components that render frequently but rarely change
When NOT to use:
- Components that always receive new props
- Simple components (overhead > benefit)
- Root-level pages
Pattern: List Virtualization
For long lists, render only visible items using react-window or react-virtualized.
typescript
1import { FixedSizeList } from 'react-window';
2
3function VirtualizedList({ items }: { items: Item[] }) {
4 const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
5 <div style={style}>
6 <ItemCard item={items[index]} />
7 </div>
8 );
9
10 return (
11 <FixedSizeList
12 height={600}
13 width="100%"
14 itemCount={items.length}
15 itemSize={80}
16 >
17 {Row}
18 </FixedSizeList>
19 );
20}
21
22// Variable height items
23import { VariableSizeList } from 'react-window';
24
25function VariableList({ items }: { items: Item[] }) {
26 const getItemSize = (index: number) => {
27 return items[index].expanded ? 200 : 80;
28 };
29
30 return (
31 <VariableSizeList
32 height={600}
33 width="100%"
34 itemCount={items.length}
35 itemSize={getItemSize}
36 >
37 {Row}
38 </VariableSizeList>
39 );
40}
When to virtualize:
- Lists with 100+ items
- Complex item components
- Scrollable containers with many children
Pattern: Zustand Selector Optimization
Problem: Selecting entire store causes re-render on any state change.
typescript
1// ❌ WRONG: Re-renders on ANY store change
2const store = useAppStore();
3// or
4const { items, loading, filters, ... } = useAppStore();
5
6// ✅ CORRECT: Only re-renders when selected values change
7const items = useAppStore((s) => s.items);
8const loading = useAppStore((s) => s.loading);
9
10// ✅ CORRECT: Multiple values with shallow comparison
11import { useShallow } from 'zustand/react/shallow';
12
13const { items, loading } = useAppStore(
14 useShallow((s) => ({
15 items: s.items,
16 loading: s.loading
17 }))
18);
Pattern: Avoiding Re-Renders
Object/Array Stability
typescript
1// ❌ WRONG: New object every render
2<ChildComponent style={{ padding: 10 }} />
3<ChildComponent config={{ enabled: true }} />
4
5// ✅ CORRECT: Stable reference
6const style = useMemo(() => ({ padding: 10 }), []);
7const config = useMemo(() => ({ enabled: true }), []);
8
9<ChildComponent style={style} />
10<ChildComponent config={config} />
11
12// ✅ CORRECT: Or define outside component
13const style = { padding: 10 };
14
15function Parent() {
16 return <ChildComponent style={style} />;
17}
Children Stability
typescript
1// ❌ WRONG: Inline function creates new element each render
2<Parent>
3 {() => <Child />}
4</Parent>
5
6// ✅ CORRECT: Stable element
7const child = useMemo(() => <Child />, [deps]);
8<Parent>{child}</Parent>
Pattern: Code Splitting
typescript
1import { lazy, Suspense } from 'react';
2
3// Lazy load components
4const Dashboard = lazy(() => import('./pages/Dashboard'));
5const Settings = lazy(() => import('./pages/Settings'));
6
7function App() {
8 return (
9 <Suspense fallback={<Loading />}>
10 <Routes>
11 <Route path="/dashboard" element={<Dashboard />} />
12 <Route path="/settings" element={<Settings />} />
13 </Routes>
14 </Suspense>
15 );
16}
17
18// Named exports
19const Dashboard = lazy(() =>
20 import('./pages/Dashboard').then(module => ({
21 default: module.Dashboard
22 }))
23);
Pattern: Debouncing and Throttling
typescript
1import { useMemo } from 'react';
2import { debounce, throttle } from 'lodash-es';
3
4// Debounce - wait until user stops typing
5function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
6 const debouncedSearch = useMemo(
7 () => debounce(onSearch, 300),
8 [onSearch]
9 );
10
11 return (
12 <input
13 type="text"
14 onChange={(e) => debouncedSearch(e.target.value)}
15 />
16 );
17}
18
19// Throttle - limit how often function runs
20function InfiniteScroll({ onLoadMore }: { onLoadMore: () => void }) {
21 const throttledLoad = useMemo(
22 () => throttle(onLoadMore, 1000),
23 [onLoadMore]
24 );
25
26 useEffect(() => {
27 const handleScroll = () => {
28 if (nearBottom()) {
29 throttledLoad();
30 }
31 };
32
33 window.addEventListener('scroll', handleScroll);
34 return () => window.removeEventListener('scroll', handleScroll);
35 }, [throttledLoad]);
36
37 return <div>...</div>;
38}
Pattern: Image Optimization
typescript
1// Lazy load images
2<img
3 src={imageUrl}
4 loading="lazy"
5 alt="Description"
6/>
7
8// With intersection observer for more control
9function LazyImage({ src, alt }: { src: string; alt: string }) {
10 const [isVisible, setIsVisible] = useState(false);
11 const imgRef = useRef<HTMLDivElement>(null);
12
13 useEffect(() => {
14 const observer = new IntersectionObserver(
15 ([entry]) => {
16 if (entry.isIntersecting) {
17 setIsVisible(true);
18 observer.disconnect();
19 }
20 },
21 { rootMargin: '100px' }
22 );
23
24 if (imgRef.current) {
25 observer.observe(imgRef.current);
26 }
27
28 return () => observer.disconnect();
29 }, []);
30
31 return (
32 <div ref={imgRef}>
33 {isVisible ? (
34 <img src={src} alt={alt} />
35 ) : (
36 <div className="placeholder" />
37 )}
38 </div>
39 );
40}
41
42// Next.js Image component (if using Next.js)
43import Image from 'next/image';
44
45<Image
46 src={imageUrl}
47 alt="Description"
48 width={400}
49 height={300}
50 placeholder="blur"
51 blurDataURL={blurHash}
52/>
Pattern: Web Workers for Heavy Computation
typescript
1// worker.ts
2self.onmessage = (e: MessageEvent<{ data: number[] }>) => {
3 const result = heavyComputation(e.data.data);
4 self.postMessage(result);
5};
6
7// Component
8function DataProcessor({ data }: { data: number[] }) {
9 const [result, setResult] = useState(null);
10
11 useEffect(() => {
12 const worker = new Worker(new URL('./worker.ts', import.meta.url));
13
14 worker.onmessage = (e) => {
15 setResult(e.data);
16 };
17
18 worker.postMessage({ data });
19
20 return () => worker.terminate();
21 }, [data]);
22
23 return result ? <Results data={result} /> : <Loading />;
24}
Pattern: Detecting Re-Renders
- Open React DevTools
- Go to Profiler tab
- Click record, interact, stop
- Review "Flamegraph" for render times
- Look for components rendering unnecessarily
why-did-you-render
typescript
1// Setup in development
2import React from 'react';
3
4if (process.env.NODE_ENV === 'development') {
5 const whyDidYouRender = require('@welldone-software/why-did-you-render');
6 whyDidYouRender(React, {
7 trackAllPureComponents: true,
8 });
9}
10
11// Mark specific component for tracking
12ItemCard.whyDidYouRender = true;
Console Logging
typescript
1// Quick check for re-renders
2function ItemCard({ item }: Props) {
3 console.log('ItemCard render:', item.id);
4 // ...
5}
Before shipping:
Common Issues
| Issue | Solution |
|---|
| List scroll lag | Virtualize list, memoize items |
| Component re-renders too often | Check selector specificity, memoize props |
| Slow initial render | Code split, reduce bundle size |
| Memory growing | Check for event listener cleanup, state accumulation |
| UI freezes on interaction | Move computation to web worker or defer |
Relationship to Other Skills
- react-zustand-patterns: Selector optimization patterns
- react-async-patterns: Proper async handling prevents re-render loops