Advanced React patterns and performance: what actually matters
Most React performance problems trace back to the same short list of mistakes — state placed too high, derived values stored as state, context doing jobs it wasn't built for. This post works through each one with real before/after code, so you know not just what to fix but why it was wrong in the first place.
Most React tutorials stop at useState and useEffect. That's fine for getting started, but at some point you're staring at a profiler trace showing 400ms renders on a component that wraps a button, and the tutorial isn't going to save you.

This post covers the patterns that separate workable React code from React code that holds up at scale. Not theory — working decisions, with the trade-offs included.
1. Memoization: use it less than you think
React.memo, useMemo, and useCallback exist to prevent unnecessary re-renders. The mistake most developers make is treating them as free performance wins. They aren't.
Every memoized value adds a dependency comparison on every render. If your component renders cheaply, that comparison costs more than the re-render you're preventing. Memoization is worth it when components receive stable props but re-render because a parent re-renders frequently, when calculations are genuinely expensive (sorting or filtering large datasets, deriving complex state), or when callbacks are passed to deeply nested children that use referential equality checks. It's not worth it for primitive props (React compares those by value anyway), components that render infrequently regardless, or simple JSX trees that React can reconcile in under a millisecond.
Here's the classic mistake:
// Adds comparison overhead with no real benefit
const Button = React.memo(({ label, onClick }: { label: string; onClick: () => void }) => (
<button onClick={onClick}>{label}</button>
));
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Button label="Submit" onClick={handleClick} />;
}The button re-renders in microseconds. React.memo here is cargo cult optimization.
Here's a case where it earns its keep:
// A table row inside a frequently-updating parent
const DataRow = React.memo(({ row }: { row: RowData }) => {
return (
<tr>
<td>{row.name}</td>
<td>{row.value}</td>
<td><StatusBadge status={row.status} /></td>
</tr>
);
}, (prevProps, nextProps) => {
// Custom comparison — only re-render if the row data actually changed
return prevProps.row.id === nextProps.row.id && prevProps.row.updatedAt === nextProps.row.updatedAt;
});The custom comparator lets you skip deep equality checks when you know exactly which fields matter.
Reach for the profiler before you reach for memo. Optimizing unmeasured code is guessing.
2. State placement is the real performance lever
Most unnecessary re-renders aren't a memoization problem. They're a state placement problem.

State that lives high in the tree re-renders everything below it. If you put your modal's isOpen flag in your root layout component, every modal toggle re-renders your entire app.
// isOpen lives in the parent — Sidebar and MainContent re-render on every toggle
function Dashboard() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<Sidebar />
<MainContent />
<button onClick={() => setIsModalOpen(true)}>Open</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
);
}
// State lives in the component that actually needs it
function ModalTrigger() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && <Modal onClose={() => setIsOpen(false)} />}
</>
);
}
function Dashboard() {
return (
<div>
<Sidebar />
<MainContent />
<ModalTrigger /> {/* only this subtree re-renders on toggle */}
</div>
);
}Same behavior, much smaller re-render surface. When state does need to go up, lift it only as far as the lowest common ancestor of the components that need it — no higher.
3. Context is not a state manager
React.Context is useful for passing stable values through the component tree without prop drilling — theme, locale, the current user. It isn't designed for frequently-changing state.

When a context value changes, every component consuming that context re-renders, even if it only uses a subset of the value.
// Every consumer re-renders on ANY change to this object,
// even if the component only reads `theme`
const AppContext = createContext<{
theme: string;
user: User;
notifications: Notification[];
sidebarOpen: boolean;
}>(null!);The fix is to split context by update frequency:
// Stable values — theme and user change rarely
const ThemeContext = createContext<{ theme: string }>(null!);
const UserContext = createContext<{ user: User }>(null!);
// Volatile values — isolated so only notification consumers re-render
const NotificationContext = createContext<{
notifications: Notification[];
addNotification: (n: Notification) => void;
}>(null!);For anything that updates frequently — form state, UI state, real-time data — use a purpose-built solution: Zustand, Jotai, or Redux Toolkit. Context wasn't built for that job.
4. Derived state belongs in useMemo, not useState
One of the most common sources of bugs and stale state is storing derived values in state:
// filteredItems must be kept in sync with items and query manually
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
useEffect(() => {
setFilteredItems(items.filter(item => item.name.includes(query)));
}, [items, query]);
return <List items={filteredItems} />;
}This triggers two renders for every filter: one when query changes, one when the effect fires and sets filteredItems. It also opens the door to stale state bugs.
// Derive it during render, memoize if the list is large
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const filteredItems = useMemo(
() => items.filter(item => item.name.toLowerCase().includes(query.toLowerCase())),
[items, query]
);
return <List items={filteredItems} />;
}One render per change, no sync logic, no stale state risk. The useMemo wrapper is only worth adding if items is large — for small arrays, the inline filter is fine without it.
5. Code splitting with React.lazy and Suspense
The fastest code is code that doesn't load. React.lazy defers loading a component's bundle until it's actually rendered.
import { lazy, Suspense } from 'react';
// The chart library only loads when ChartView is actually rendered
const ChartView = lazy(() => import('./ChartView'));
function Dashboard() {
const [tab, setTab] = useState<'table' | 'chart'>('table');
return (
<div>
<TabBar current={tab} onChange={setTab} />
{tab === 'table' && <DataTable />}
{tab === 'chart' && (
<Suspense fallback={<Spinner />}>
<ChartView />
</Suspense>
)}
</div>
);
}The chart bundle — Recharts, D3, or whatever you're using — won't be requested until the user switches to that tab. For users who never do, those bytes are never downloaded.
You can nest Suspense boundaries to control loading granularity. A boundary closer to the lazy component gives you a more targeted fallback, so an unrelated part of the UI doesn't go into a loading state because one chart is fetching.

6. useEffect: the smaller, the better
useEffect is where a lot of performance problems hide, mostly because it encourages doing more than you need to.
Skip effects for synchronous derivations. If you can compute something during render, do it there — the derived state section above is the canonical example.
Cleanup is not optional. Any effect that sets up a subscription, timer, or event listener needs to return a cleanup function:
// Will keep firing after unmount
useEffect(() => {
const interval = setInterval(fetchLatestData, 5000);
}, []);
// Safe
useEffect(() => {
const interval = setInterval(fetchLatestData, 5000);
return () => clearInterval(interval);
}, []);Separate effects by concern. One effect doing three unrelated things is harder to reason about and harder to clean up correctly:
// Two unrelated concerns tangled together
useEffect(() => {
document.title = `${user.name} — Dashboard`;
const socket = openSocket(user.id);
socket.on('update', handleUpdate);
return () => {
socket.disconnect();
};
}, [user.id, user.name]);
// One concern per effect
useEffect(() => {
document.title = `${user.name} — Dashboard`;
}, [user.name]);
useEffect(() => {
const socket = openSocket(user.id);
socket.on('update', handleUpdate);
return () => socket.disconnect();
}, [user.id]);The second version lets each effect have its own dependency array, its own cleanup, and its own reason to exist.
7. Virtualize long lists
Rendering 1,000 DOM nodes because a list has 1,000 items looks fine in development and tanks production. If you're rendering a long, scrollable list, only the visible items need to exist in the DOM.
react-window handles this well:
import { FixedSizeList } from 'react-window';
function VirtualizedTable({ rows }: { rows: Row[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<DataRow row={rows[index]} />
</div>
);
return (
<FixedSizeList
height={600}
itemCount={rows.length}
itemSize={52}
width="100%"
>
{Row}
</FixedSizeList>
);
}At any point, only the visible rows plus a small overscan buffer exist in the DOM. Scroll performance stays constant whether the list has 100 items or 100,000.
For dynamic item heights, use VariableSizeList. For grids, react-window includes FixedSizeGrid and VariableSizeGrid.
8. Measure before you optimize
React DevTools includes a Profiler that records what rendered, why it rendered, and how long it took. Use it before touching any of the above.
Three questions worth answering from every profile: what is rendering (look for components rendering frequently that you didn't expect), why it's rendering (the profiler distinguishes prop changes, state changes, and context updates), and how long it's taking (renders over ~16ms are where you drop below 60fps).
import { Profiler } from 'react';
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
) {
if (actualDuration > 16) {
console.warn(`Slow render in ${id}: ${actualDuration.toFixed(1)}ms (${phase})`);
}
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}This logs any render over 16ms in development — useful for catching regressions before they ship, without waiting for a user to report sluggishness.
The short version
React performance problems almost always trace back to the same causes: state too high in the tree, derived values stored as state, context updating too often, and rendering more than needs to be rendered. Memoization helps, but it rarely fixes the root issue.
Keep state close to where it's used. Compute, don't store. Measure before you optimize. If you haven't opened the React Profiler yet, that's the first step.