
Mykola Nesterchuk - March 4, 2026
Performance is one of those things you don't think about - until users start complaining. A slow React app can kill engagement, increase bounce rates, and make even beautiful UI feel broken. The good news is that most performance issues follow predictable patterns, and fixing them doesn't require a rewrite.
This article covers the most impactful techniques you can apply today.
By default, a React component re-renders every time its parent re-renders - even if its own props haven't changed. React.memo wraps a component and skips the re-render if props are shallowly equal.
const UserCard = React.memo(({ name, avatar }) => { return ( <div> <img src={avatar} alt={name} /> <span>{name}</span> </div> ); });
Use this for components that are expensive to render and receive the same props frequently - like list items, cards, or table rows.
Note:
React.memodoes a shallow comparison. If you pass objects or arrays as props, make sure their references don't change on every render.
When you pass a function as a prop, it gets recreated on every render - which breaks React.memo because the reference changes even if the logic is identical.
// ❌ New function reference every render const handleClick = () => { doSomething(id); }; // ✅ Stable reference const handleClick = useCallback(() => { doSomething(id); }, [id]);
Pair useCallback with React.memo for maximum effect.
If you're computing something heavy on every render - filtering a large list, running a complex algorithm, building a derived structure - useMemo lets you cache the result and recompute only when dependencies change.
const filteredPosts = useMemo(() => { return posts.filter(post => post.title.toLowerCase().includes(query.toLowerCase()) ); }, [posts, query]);
Don't overuse it - useMemo itself has overhead. Only reach for it when profiling shows a real bottleneck.
Bundling your entire app into one JS file means users download code for pages they may never visit. Code splitting fixes this.
import { lazy, Suspense } from 'react'; const Dashboard = lazy(() => import('./pages/Dashboard')); function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Dashboard /> </Suspense> ); }
React Router works great with this pattern - lazy load each route component and let Suspense handle the loading state.
Rendering 1000 DOM nodes at once is slow. If you have a long list - messages, search results, data tables - only render what's visible in the viewport.
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'; const List = ({ items }) => { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 64, }); return ( <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}> <div style={{ height: virtualizer.getTotalSize() }}> {virtualizer.getVirtualItems().map(virtualRow => ( <div key={virtualRow.index} style={{ position: 'absolute', top: virtualRow.start, height: virtualRow.size, }} > {items[virtualRow.index].name} </div> ))} </div> </div> ); };
One of the most overlooked optimizations. If a piece of state is only used by one subtree, keep it there - don't lift it to the top level.
// ❌ App re-renders every time the input changes function App() { const [query, setQuery] = useState(''); return ( <div> <HeavyComponent /> <SearchInput query={query} onChange={setQuery} /> </div> ); } // ✅ Only SearchInput re-renders function App() { return ( <div> <HeavyComponent /> <SearchWidget /> </div> ); } function SearchWidget() { const [query, setQuery] = useState(''); return <SearchInput query={query} onChange={setQuery} />; }
Before optimizing anything, measure. The React DevTools Profiler shows exactly which components re-rendered, how long they took, and why.
Optimize what the profiler shows, not what you guess.
Performance work is most effective when it's targeted. Here's a quick reference:
React.memo + useCallbackuseMemolazy + Suspense