Strategic Software Engineer and Business Problem Solver - Merveille Alexandre
blogadvanced

React Performance Optimization: Beyond useMemo and useCallback

Discover advanced React performance optimization techniques that go beyond the basics and significantly improve your app's speed.

January 25, 2024
14 min read
Merveille Alexander

React Performance Optimization: Beyond useMemo and useCallback


The Moment I Realized I Had No Idea What I Was Doing

It was 2:47 AM on a Wednesday. I was hunched over my laptop, nursing my 2 cup of tea, staring at a Chrome DevTools performance tab that looked like a Jackson Pollock painting. My React app, which had been running smoothly for months, was suddenly dropping frames like it was paid by the dropped frame.

I had just deployed what I thought was a harmless update—a new dashboard feature for our enterprise customers. Nothing crazy, just a few charts, some data tables, and a bunch of interactive filters. But somewhere between my local machine and production, performance had fallen off a cliff.

"My God," I whispered to myself, "what have I done?"

Like any self-respecting React developer, my first instinct was to sprinkle useMemo and useCallback everywhere like fairy dust. I wrapped functions, memoized values, did everything the blog posts told me to do.

Code
// Me, thinking I was being clever
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);
const memoizedComponent = useMemo(() => <ExpensiveComponent />, []);

The app got... slightly less terrible. But it was still slow. And now my codebase was littered with memoization hooks that I didn't fully understand, making everything harder to read and debug.

That night, I made a promise to myself: I was going to learn how React actually works, not just how to make it work. Two years later, I want to share what I discovered about performance optimization that goes beyond the usual advice.


The Problem with useMemo and useCallback

Before we dive into the advanced stuff, let's have an honest conversation about our favorite hooks. I love them. I use them every day. But they're not the performance magic bullets that many tutorials make them out to be.

The Hidden Cost of Memoization

Here's something that surprised me: memoization itself has a cost. Every time you use useMemo or useCallback, React has to:

  1. Store the previous dependencies
  2. Compare them with the current dependencies
  3. Decide whether to recompute or return the cached value
Code
// This seems innocent enough
const total = useMemo(() => {
  return items.reduce((sum, item) => sum + item.price, 0);
}, [items]);

// But React is doing work behind the scenes:
// - Store previous items array reference
// - Compare each item in the array (shallow)
// - Run the reducer only if something changed

For simple calculations, the memoization check might actually be more expensive than just recalculating the value. I learned this the hard way when I memoized literally everything in a component and made performance worse.

The Memoization Illusion

Another thing nobody tells you: useCallback doesn't make functions faster. Functions are cheap to create. The problem is referential equality:

Code
function ParentComponent() {
  // Without useCallback, this is a new function every render
  const handleClick = () => {
    console.log('Clicked!');
  };
  
  // This child will re-render because handleClick changes
  return <ChildComponent onClick={handleClick} />;
}

// Memoized child
const ChildComponent = React.memo(({ onClick }) => {
  console.log('Child rendered!');
  return <button onClick={onClick}>Click me</button>;
});

useCallback prevents the child from re-rendering unnecessarily. But if the child isn't memoized with React.memo, useCallback does absolutely nothing for performance.

I spent months wrapping callbacks in useCallback without memoizing the components that received them. Waste of time. Waste of mental energy. Waste of code readability.


What Actually Causes Performance Problems

After that 2:47 AM epiphany, I started digging into real performance bottlenecks. Here's what I found:

1. Unnecessary Renders (The Silent Killer)

Most performance problems in React apps come from components rendering when they shouldn't. It's not that React is slow—it's that we're asking it to do too much work.

Code
// This component causes pain
function Dashboard() {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [selectedTab, setSelectedTab] = useState('overview');
  
  // This expensive table renders on EVERY state change
  return (
    <div>
      <UserHeader user={user} />
      <NotificationCenter notifications={notifications} />
      <TabNav selected={selectedTab} onSelect={setSelectedTab} />
      <ExpensiveDataTable /> {/* Re-renders all the time! */}
    </div>
  );
}

Every time user, notifications, or selectedTab changes, the entire Dashboard re-renders, including ExpensiveDataTable that doesn't depend on any of them.

2. Prop Drilling and Context Overuse

Context is amazing. I love Context. But I've also seen it bring applications to their knees.

Code
// This looks convenient
const AppContext = React.createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState({});
  const [analytics, setAnalytics] = useState({});
  
  // Everything in here changes all the time
  const value = {
    user, setUser,
    theme, setTheme,
    notifications, setNotifications,
    settings, setSettings,
    analytics, setAnalytics
  };
  
  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

// Any component that consumes this context re-renders
// when ANY of these values change, even if it only uses 'theme'
function ThemeSwitcher() {
  const { theme, setTheme } = useContext(AppContext);
  // This re-renders when user, notifications, settings, or analytics change!
  return <button onClick={() => setTheme('dark')}>Switch</button>;
}

I once worked on an app where a simple theme switcher was causing re-renders across the entire application because it was consuming a massive context object.

3. Expensive Render Operations

Sometimes, components are just doing too much work:

Code
function DataGrid({ rows }) {
  return (
    <div>
      {rows.map(row => (
        <Row key={row.id} data={row}>
          {row.cells.map(cell => (
            <Cell key={cell.id}>
              {formatCellValue(cell)} {/* Expensive formatting */}
              {renderCharts(cell.charts)} {/* Heavy visualization */}
              {validateData(cell)} {/* Data validation on every render */}
            </Cell>
          ))}
        </Row>
      ))}
    </div>
  );
}

When every cell is doing expensive calculations on every render, even a small number of re-renders can grind your app to a halt.


Strategy 1: Component Composition Over Memoization

Here's the first lesson that changed everything for me: how you structure your components matters more than memoization hooks.

The Fix: Lift Content Up

Instead of memoizing everything, I learned to compose components better:

Code
// Bad: Everything re-renders together
function Dashboard() {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  
  return (
    <div>
      <UserHeader user={user} />
      <NotificationCenter notifications={notifications} />
      <ExpensiveDataTable />
    </div>
  );
}

// Good: Isolate state to where it's needed
function Dashboard() {
  return (
    <div>
      <UserSection />
      <NotificationSection />
      <ExpensiveDataTable />
    </div>
  );
}

function UserSection() {
  const [user, setUser] = useState(null);
  return <UserHeader user={user} />;
}

function NotificationSection() {
  const [notifications, setNotifications] = useState([]);
  return <NotificationCenter notifications={notifications} />;
}

Now when user changes, only UserSection re-renders. The expensive table stays untouched. No memoization needed.

The Children Pattern

Another pattern I use constantly:

Code
// Instead of this
function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Clicked {count} times
      </button>
      <ExpensiveComponent data={someData} />
    </div>
  );
}

// Do this
function Parent({ children }) {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Clicked {count} times
      </button>
      {children}
    </div>
  );
}

function App() {
  return (
    <Parent>
      <ExpensiveComponent data={someData} />
    </Parent>
  );
}

The ExpensiveComponent is passed as children and doesn't re-render when count changes. This is one of those patterns that seems simple but is incredibly powerful.


Strategy 2: State Management Architecture

After composition, the next biggest win came from rethinking how I manage state.

Splitting Context Like a Pro

Instead of one massive context:

Code
// Split contexts by responsibility
const UserContext = React.createContext();
const ThemeContext = React.createContext();
const NotificationContext = React.createContext();
const SettingsContext = React.createContext();

function AppProviders({ children }) {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotificationProvider>
          <SettingsProvider>
            {children}
          </SettingsProvider>
        </NotificationProvider>
      </ThemeProvider>
    </UserProvider>
  );
}

// Now components only subscribe to what they need
function ThemeSwitcher() {
  const { theme, setTheme } = useContext(ThemeContext);
  // Only re-renders when theme changes!
  return <button onClick={() => setTheme('dark')}>Switch</button>;
}

This pattern alone reduced re-renders in one of my apps by about 60%.

Using State Libraries Wisely

Sometimes, you need more sophisticated state management. I've had great success with Zustand for global state:

Code
import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  notifications: [],
  theme: 'light',
  setUser: (user) => set({ user }),
  addNotification: (notification) => 
    set((state) => ({ 
      notifications: [...state.notifications, notification] 
    })),
  setTheme: (theme) => set({ theme }),
}));

// Components only subscribe to the slices they use
function ThemeSwitcher() {
  const theme = useStore((state) => state.theme);
  const setTheme = useStore((state) => state.setTheme);
  // Only re-renders when theme changes!
  return <button onClick={() => setTheme('dark')}>Switch</button>;
}

function UserProfile() {
  const user = useStore((state) => state.user);
  // Only re-renders when user changes!
  return <div>{user?.name}</div>;
}

Zustand's selector pattern gives you fine-grained subscriptions without the complexity of Redux.


Strategy 3: Virtualization for Large Lists

This one seems obvious, but you'd be surprised how many apps skip it. If you're rendering more than 50-100 items in a list, you need virtualization.

Code
// Without virtualization: Slow
function ProductList({ products }) {
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// With virtualization: Fast, even with 10,000 items
import { FixedSizeList } from 'react-window';

function ProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  );
  
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={products.length}
      itemSize={120}
    >
      {Row}
    </FixedSizeList>
  );
}

I used react-window on a project that rendered 5,000 items in a grid. The difference was night and day—from 2 seconds to render to 60fps scrolling.


Strategy 4: Code Splitting and Lazy Loading

This is the low-hanging fruit that everyone talks about but few actually implement correctly.

Route-Based Splitting

Code
// Instead of importing everything upfront
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';

// Do this
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

Component-Level Splitting

For heavy components that aren't immediately visible:

Code
import { lazy, Suspense, useState } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        Show Chart
      </button>
      
      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <HeavyChart />
        </Suspense>
      )}
      
      <Suspense fallback={<div>Loading table...</div>}>
        <DataTable />
      </Suspense>
    </div>
  );
}

This pattern ensures users only download code they actually need, when they need it.


Strategy 5: Windowing and Debouncing

Sometimes the problem isn't rendering—it's event handling.

Debouncing Expensive Operations

Code
import { debounce } from 'lodash';
import { useMemo } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // Debounce the search to avoid API calls on every keystroke
  const debouncedSearch = useMemo(
    () => debounce(async (searchQuery) => {
      const data = await searchAPI(searchQuery);
      setResults(data);
    }, 300),
    []
  );
  
  const handleChange = (e) => {
    setQuery(e.target.value);
    debouncedSearch(e.target.value);
  };
  
  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </div>
  );
}

Throttling Scroll Events

Code
import { throttle } from 'lodash';
import { useEffect, useMemo } from 'react';

function InfiniteScroll() {
  const handleScroll = useMemo(
    () => throttle(() => {
      // Check if user scrolled near bottom
      if (isNearBottom()) {
        loadMoreItems();
      }
    }, 100),
    []
  );
  
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);
  
  return <div>{/* items */}</div>;
}

Strategy 6: The React Compiler (Coming Soon)

I have to mention this because it's going to change everything. The React team is working on a compiler that automatically memoizes your code.

Code
// Today: You write this with manual memoization
function Component({ data }) {
  const processed = useMemo(() => process(data), [data]);
  const handleClick = useCallback(() => {
    console.log(processed);
  }, [processed]);
  
  return <Child onClick={handleClick} />;
}

// Tomorrow: You write this, compiler handles the rest
function Component({ data }) {
  const processed = process(data);
  const handleClick = () => console.log(processed);
  
  return <Child onClick={handleClick} />;
}

The compiler will analyze your code and automatically add the memoization where it makes sense. No more useMemo and useCallback everywhere. I can't wait.


Putting It All Together: A Real-World Example

Let me show you how I applied these principles to fix that dashboard that was keeping me up at 2:47 AM.

The Problem Component

Code
function AnalyticsDashboard() {
  const [dateRange, setDateRange] = useState('week');
  const [filters, setFilters] = useState({});
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetchData(dateRange, filters).then(setData);
  }, [dateRange, filters]);
  
  return (
    <div>
      <Header>
        <DatePicker value={dateRange} onChange={setDateRange} />
        <FilterBar filters={filters} onChange={setFilters} />
      </Header>
      
      <KPICharts data={data?.kpis} />
      <RevenueChart data={data?.revenue} />
      <UserGrowthChart data={data?.users} />
      <ConversionFunnel data={data?.conversions} />
      <DataTable data={data?.raw} />
    </div>
  );
}

The Fixed Version

Code
// Split into smaller, focused components
function AnalyticsDashboard() {
  return (
    <div>
      <DashboardHeader />
      <DashboardContent />
    </div>
  );
}

// Isolate data fetching and state
function DashboardHeader() {
  const [dateRange, setDateRange] = useState('week');
  const [filters, setFilters] = useState({});
  
  return (
    <Header>
      <DatePicker value={dateRange} onChange={setDateRange} />
      <FilterBar filters={filters} onChange={setFilters} />
    </Header>
  );
}

// Data fetching in its own component
function DashboardContent() {
  const { dateRange, filters } = useDashboardContext();
  const { data, isLoading } = useDashboardData(dateRange, filters);
  
  if (isLoading) return <LoadingSpinner />;
  
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <KPICharts data={data.kpis} />
      <RevenueChart data={data.revenue} />
      <UserGrowthChart data={data.users} />
      <ConversionFunnel data={data.conversions} />
      <VirtualizedTable data={data.raw} />
    </Suspense>
  );
}

// Custom hook for data fetching
function useDashboardData(dateRange, filters) {
  return useQuery(
    ['dashboard', dateRange, filters],
    () => fetchDashboardData(dateRange, filters),
    {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
    }
  );
}

The result? The dashboard went from dropping frames on every interaction to running at a smooth 60fps. No excessive memoization, just thoughtful component design and proper data management.


The Performance Checklist

Here's what I run through now when optimizing React apps:

Component Structure

  • Can I split this component into smaller pieces?
  • Is state as close to where it's used as possible?
  • Can I use the children pattern to prevent re-renders?
  • Are my contexts split by responsibility?

Data Management

  • Am I using proper selectors with Zustand/Redux?
  • Are my API calls cached with React Query/SWR?
  • Do I have staleTime set appropriately?
  • Am I debouncing expensive user inputs?

Rendering

  • Do I need virtualization for long lists?
  • Are heavy components lazy-loaded?
  • Is React.memo actually helping or hurting?
  • Can I move expensive calculations to Web Workers?

Monitoring

  • Have I profiled with React DevTools?
  • Do I know which components re-render too often?
  • Have I checked the bundle size with source-map-explorer?
  • Am I measuring Core Web Vitals?

What I Wish I Knew Then

If I could go back to that 2:47 AM moment and give myself advice, here's what I'd say:

Start with structure, not memoization. How you compose your components matters more than any hook. Think about state placement before you think about optimization.

Measure before you optimize. React DevTools profiler is your friend. Don't guess where the bottlenecks are—find them.

The best optimization is the one you don't need to write. If your component structure is right, you'll need far fewer memoization hooks.

Performance is a feature, not an afterthought. Build with performance in mind from the start. It's much harder to retroactively fix a slow app than to build a fast one.


The Future Is Bright

As I'm writing this, the React team is working on the React Compiler, automatic batching is already here, and new patterns are emerging that make performance optimization more accessible than ever.

But the fundamentals remain the same: understand how React works, structure your components thoughtfully, and measure everything. The hooks and tools will change, but these principles will serve you well regardless of what comes next.

That dashboard I was debugging at 2:47 AM? It's running beautifully now. Our enterprise customers love it. And I finally get to sleep through the night.


Enjoyed this post? I write about React performance, architecture, and real-world development every week. Follow me on X @themarvelbiz. And if you have a performance horror story of your own, Message me!


Written by Merveille Alexander

Software Engineer sharing knowledge and experience.