20 minlesson

Optimization Patterns and Techniques

Optimization Patterns and Techniques

Let's explore advanced patterns for building performant React applications.

State Colocation

Keep state close to where it's used:

jsx
1// Bad - Lifting state too high causes unnecessary re-renders
2function App() {
3 const [searchQuery, setSearchQuery] = useState('');
4
5 return (
6 <div>
7 <Header /> {/* Re-renders on every keystroke! */}
8 <SearchBox query={searchQuery} setQuery={setSearchQuery} />
9 <Footer /> {/* Re-renders on every keystroke! */}
10 </div>
11 );
12}
13
14// Good - State stays where it's needed
15function App() {
16 return (
17 <div>
18 <Header />
19 <SearchBox /> {/* Manages its own state */}
20 <Footer />
21 </div>
22 );
23}
24
25function SearchBox() {
26 const [query, setQuery] = useState('');
27
28 return (
29 <input
30 value={query}
31 onChange={e => setQuery(e.target.value)}
32 placeholder="Search..."
33 />
34 );
35}

State Splitting

Split unrelated state to minimize re-renders:

jsx
1// Bad - One state object for unrelated data
2function Dashboard() {
3 const [state, setState] = useState({
4 user: null,
5 notifications: [],
6 theme: 'light',
7 sidebarOpen: false
8 });
9
10 // Changing theme re-renders everything that uses state
11}
12
13// Good - Separate states for separate concerns
14function Dashboard() {
15 const [user, setUser] = useState(null);
16 const [notifications, setNotifications] = useState([]);
17 const [theme, setTheme] = useState('light');
18 const [sidebarOpen, setSidebarOpen] = useState(false);
19
20 // Changing theme only affects theme-dependent components
21}

Component Composition for Performance

Use children to avoid re-renders:

jsx
1// Bad - ColorPicker re-renders on every count change
2function App() {
3 const [count, setCount] = useState(0);
4
5 return (
6 <div>
7 <button onClick={() => setCount(c => c + 1)}>
8 Count: {count}
9 </button>
10 <ExpensiveTree />
11 </div>
12 );
13}
14
15// Good - ExpensiveTree passed as children, doesn't re-render
16function App() {
17 return (
18 <Counter>
19 <ExpensiveTree />
20 </Counter>
21 );
22}
23
24function Counter({ children }) {
25 const [count, setCount] = useState(0);
26
27 return (
28 <div>
29 <button onClick={() => setCount(c => c + 1)}>
30 Count: {count}
31 </button>
32 {children} {/* Already created, won't re-render! */}
33 </div>
34 );
35}

Why this works: children is created in App (which doesn't re-render), so the reference stays stable.

Windowing Long Lists

Use virtualization for long lists:

jsx
1import { useVirtualizer } from '@tanstack/react-virtual';
2
3function VirtualList({ items }) {
4 const parentRef = useRef(null);
5
6 const virtualizer = useVirtualizer({
7 count: items.length,
8 getScrollElement: () => parentRef.current,
9 estimateSize: () => 50,
10 overscan: 5
11 });
12
13 return (
14 <div
15 ref={parentRef}
16 style={{ height: '400px', overflow: 'auto' }}
17 >
18 <div
19 style={{
20 height: `${virtualizer.getTotalSize()}px`,
21 position: 'relative'
22 }}
23 >
24 {virtualizer.getVirtualItems().map(virtualRow => (
25 <div
26 key={virtualRow.key}
27 style={{
28 position: 'absolute',
29 top: 0,
30 transform: `translateY(${virtualRow.start}px)`,
31 height: `${virtualRow.size}px`
32 }}
33 >
34 {items[virtualRow.index].name}
35 </div>
36 ))}
37 </div>
38 </div>
39 );
40}

Debouncing Expensive Operations

Delay expensive updates:

jsx
1function SearchResults({ query }) {
2 const debouncedQuery = useDebounce(query, 300);
3
4 // Only fetch when user stops typing
5 const { data } = useFetch(
6 debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
7 );
8
9 return <ResultsList results={data} />;
10}

For input state, consider useDeferredValue:

jsx
1import { useDeferredValue } from 'react';
2
3function Search() {
4 const [query, setQuery] = useState('');
5 const deferredQuery = useDeferredValue(query);
6
7 // Input stays responsive, expensive render is deferred
8 return (
9 <>
10 <input
11 value={query}
12 onChange={e => setQuery(e.target.value)}
13 />
14 <ExpensiveResults query={deferredQuery} />
15 </>
16 );
17}

useTransition for Non-Urgent Updates

Mark updates as non-urgent:

jsx
1import { useTransition } from 'react';
2
3function TabContainer() {
4 const [tab, setTab] = useState('home');
5 const [isPending, startTransition] = useTransition();
6
7 function selectTab(nextTab) {
8 startTransition(() => {
9 setTab(nextTab); // Non-urgent: can be interrupted
10 });
11 }
12
13 return (
14 <>
15 <TabButton onClick={() => selectTab('home')}>Home</TabButton>
16 <TabButton onClick={() => selectTab('posts')}>Posts</TabButton>
17 <TabButton onClick={() => selectTab('contact')}>Contact</TabButton>
18
19 <div style={{ opacity: isPending ? 0.7 : 1 }}>
20 {tab === 'home' && <Home />}
21 {tab === 'posts' && <Posts />} {/* Expensive! */}
22 {tab === 'contact' && <Contact />}
23 </div>
24 </>
25 );
26}

Benefits:

  • UI stays responsive during expensive renders
  • User can interact while transition is pending
  • Shows visual feedback via isPending

Lazy Loading Routes

Code split by route:

jsx
1import { lazy, Suspense } from 'react';
2import { Routes, Route } from 'react-router-dom';
3
4const Home = lazy(() => import('./pages/Home'));
5const Dashboard = lazy(() => import('./pages/Dashboard'));
6const Settings = lazy(() => import('./pages/Settings'));
7
8function App() {
9 return (
10 <Suspense fallback={<PageLoader />}>
11 <Routes>
12 <Route path="/" element={<Home />} />
13 <Route path="/dashboard" element={<Dashboard />} />
14 <Route path="/settings" element={<Settings />} />
15 </Routes>
16 </Suspense>
17 );
18}

Image Optimization

Load images efficiently:

jsx
1// Native lazy loading
2<img src="photo.jpg" loading="lazy" alt="Photo" />
3
4// With srcset for responsive images
5<img
6 src="photo-800.jpg"
7 srcSet="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
8 sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
9 loading="lazy"
10 alt="Photo"
11/>
12
13// Using next/image (if using Next.js)
14import Image from 'next/image';
15
16<Image
17 src="/photo.jpg"
18 width={800}
19 height={600}
20 alt="Photo"
21 placeholder="blur"
22 blurDataURL="data:image/jpeg;base64,..."
23/>

Memoization Strategies

When to Use memo()

jsx
1// Good candidates for memo:
2// 1. Pure components with expensive renders
3const ExpensiveChart = memo(function Chart({ data }) {
4 // Complex SVG rendering
5});
6
7// 2. Components that receive stable props
8const UserAvatar = memo(function Avatar({ userId }) {
9 // userId is a primitive, stable
10});
11
12// 3. List items in long lists
13const ListItem = memo(function Item({ item, onSelect }) {
14 // Prevents entire list re-rendering
15});

Custom Comparison

jsx
1const Chart = memo(
2 function Chart({ data, config }) {
3 // Render chart
4 },
5 (prevProps, nextProps) => {
6 // Custom comparison - return true to skip re-render
7 return (
8 prevProps.data.length === nextProps.data.length &&
9 prevProps.config.type === nextProps.config.type
10 );
11 }
12);

Context Optimization

Split context by change frequency:

jsx
1// Bad - Everything re-renders when anything changes
2const AppContext = createContext({ user: null, theme: 'light', cart: [] });
3
4// Good - Separate contexts
5const UserContext = createContext(null);
6const ThemeContext = createContext('light');
7const CartContext = createContext([]);
8
9// Components subscribe only to what they need
10function ThemeToggle() {
11 const [theme, setTheme] = useContext(ThemeContext);
12 // Only re-renders when theme changes
13}

Web Workers for Heavy Computation

Offload CPU-intensive work:

jsx
1// worker.js
2self.onmessage = function(e) {
3 const result = heavyComputation(e.data);
4 self.postMessage(result);
5};
6
7// Component
8function DataProcessor({ data }) {
9 const [result, setResult] = useState(null);
10
11 useEffect(() => {
12 const worker = new Worker(new URL('./worker.js', 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}

Profiling in Production

Use React's production profiler:

jsx
1import { Profiler } from 'react';
2
3function onRenderCallback(
4 id,
5 phase,
6 actualDuration,
7 baseDuration,
8 startTime,
9 commitTime
10) {
11 // Send to analytics
12 analytics.track('react_render', {
13 component: id,
14 phase,
15 duration: actualDuration
16 });
17}
18
19function App() {
20 return (
21 <Profiler id="App" onRender={onRenderCallback}>
22 <MainContent />
23 </Profiler>
24 );
25}

Summary Checklist

Before optimizing:

  • Measured with React DevTools Profiler
  • Identified actual bottlenecks
  • Verified optimization is worth the complexity

Optimization techniques:

  • State colocation - keep state close to usage
  • State splitting - separate unrelated state
  • Component composition - use children pattern
  • React.memo() - for expensive pure components
  • useMemo/useCallback - stabilize references
  • useTransition - for non-urgent updates
  • useDeferredValue - for derived expensive values
  • Virtualization - for long lists
  • Code splitting - lazy load routes/features
  • Context splitting - separate by change frequency

After optimizing:

  • Verified improvement with Profiler
  • Tested user experience
  • Code remains readable

Next, let's practice these techniques with a hands-on performance audit!