Performance Optimization for State Management

1. React.memo and State Change Prevention

Technique Syntax Purpose When to Use
React.memo React.memo(Component) Memoizes component, prevents re-render if props unchanged Pure components receiving same props frequently
Custom comparison React.memo(Comp, areEqual) Custom equality function for props comparison Complex props or specific comparison logic needed
Prop stability Stable references with useCallback/useMemo Ensures props don't change unnecessarily When passing callbacks/objects as props to memoized components
Children pattern {children} prop Children don't re-render when parent state changes Wrap expensive components in parent with local state

Example: React.memo preventing unnecessary re-renders

import { memo, useState } from 'react';

// ❌ Without memo - re-renders on every parent update
const ExpensiveChild = ({ value }) => {
  console.log('ExpensiveChild rendered');
  return <div>{value}</div>;
};

// ✅ With memo - only re-renders when value prop changes
const MemoizedChild = memo(({ value }) => {
  console.log('MemoizedChild rendered');
  return <div>{value}</div>;
});

// ✅ Custom comparison for complex props
const CustomMemoChild = memo(
  ({ user, settings }) => <div>{user.name}: {settings.theme}</div>,
  (prevProps, nextProps) => {
    // Return true if props are equal (skip re-render)
    return prevProps.user.id === nextProps.user.id &&
           prevProps.settings.theme === nextProps.settings.theme;
  }
);

function Parent() {
  const [count, setCount] = useState(0);
  const [value] = useState('constant');

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChild value={value} /> {/* Re-renders every time */}
      <MemoizedChild value={value} />  {/* Only renders once */}
    </>
  );
}
React.memo Best Practices:
  • Use for expensive components that receive same props frequently
  • Don't overuse - has its own overhead for shallow prop comparison
  • Ensure all props passed are stable (use useCallback/useMemo)
  • Custom comparison should be cheap - avoid deep equality checks
  • Children prop bypasses memo - use composition for optimization

2. useCallback Hook for Event Handler Optimization

Pattern Syntax Purpose Dependencies
useCallback useCallback(fn, deps) Memoizes function reference between renders Values used inside callback function
Event handler useCallback((e) => {...}, []) Stable reference for event handlers Empty if no external values used
With state useCallback(() => {...}, [state]) Callback using current state value Include state variables in dependency array
Functional update useCallback(() => setState(s => s + 1), []) Update state without depending on current value Empty deps - uses functional setState

Example: useCallback optimization patterns

import { useState, useCallback, memo } from 'react';

const Button = memo(({ onClick, label }) => {
  console.log(`Rendering ${label}`);
  return <button onClick={onClick}>{label}</button>;
});

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [count, setCount] = useState(0);

  // ❌ New function every render - breaks Button memo
  const handleBadAdd = () => {
    setTodos([...todos, { id: Date.now(), text: 'New' }]);
  };

  // ✅ Stable reference with functional update - empty deps
  const handleAdd = useCallback(() => {
    setTodos(prev => [...prev, { id: Date.now(), text: 'New' }]);
  }, []); // No dependencies needed with functional update

  // ✅ With dependencies - recreates when todos changes
  const handleClear = useCallback(() => {
    if (todos.length > 0) {
      setTodos([]);
    }
  }, [todos]); // Depends on todos.length check

  // ✅ Best: Use functional update to avoid dependency
  const handleClearBetter = useCallback(() => {
    setTodos(prev => prev.length > 0 ? [] : prev);
  }, []); // No dependencies - optimal

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      <Button onClick={handleAdd} label="Add Todo" />
      <Button onClick={handleClearBetter} label="Clear" />
    </div>
  );
}
useCallback Pitfalls: Don't use useCallback everywhere - it has overhead. Only use when: (1) Passing callback to memoized child component, (2) Callback is dependency of useEffect/useMemo, (3) Callback creates expensive closure. Premature optimization can make code harder to read.

3. State Splitting and Component Granularity

Strategy Implementation Benefit Use Case
Split state by concern Multiple useState calls instead of one object Components re-render only when relevant state changes Independent state variables that update separately
Collocate state Move state to component that uses it Reduces parent re-renders, limits update scope State only used by single child component
Lift state down Push state to lowest common ancestor Prevents sibling components from re-rendering State shared by subset of children
Extract components Separate stateful logic into smaller components Isolates re-renders to affected component tree Large components with multiple state updates

❌ Bad: Single state object causes unnecessary re-renders

function Form() {
  // All in one state
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    count: 0
  });

  // Every keystroke re-renders entire form
  // Even the counter that doesn't care about form inputs
  return (
    <>
      <input 
        value={formData.name}
        onChange={e => setFormData({
          ...formData, 
          name: e.target.value
        })}
      />
      <ExpensiveCounter count={formData.count} />
    </>
  );
}

✅ Good: Split state by concern

function Form() {
  // Split independent state
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [count, setCount] = useState(0);

  // Typing in name only re-renders
  // name input, not counter
  return (
    <>
      <input 
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <ExpensiveCounter count={count} />
    </>
  );
}

Example: Collocating state for performance

// ❌ State in parent - all children re-render
function Parent() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Header /> {/* Re-renders on isOpen change */}
      <Sidebar isOpen={isOpen} setIsOpen={setIsOpen} />
      <Content /> {/* Re-renders unnecessarily */}
    </>
  );
}

// ✅ State collocated in component that needs it
function Parent() {
  return (
    <>
      <Header /> {/* Never re-renders */}
      <Sidebar /> {/* Manages own state */}
      <Content /> {/* Never re-renders */}
    </>
  );
}

function Sidebar() {
  const [isOpen, setIsOpen] = useState(false);
  // State isolated to this component
  return <div>...</div>;
}

4. useMemo for Expensive State Derivations

Pattern Syntax Purpose Dependencies
useMemo useMemo(() => computation, deps) Memoizes computed value, recalculates only when deps change Input values used in computation
Expensive calculation useMemo(() => expensiveFn(data), [data]) Avoid re-computing on every render Source data for calculation
Reference equality useMemo(() => ({...obj}), [obj]) Maintain stable object/array reference Primitive values that comprise object
Filtered/sorted data useMemo(() => items.filter(...), [items]) Cache derived collections Source collection and filter criteria

Example: useMemo for expensive computations

import { useState, useMemo } from 'react';

function DataTable({ data, sortKey }) {
  const [filter, setFilter] = useState('');

  // ❌ Recalculates on every render (even unrelated state changes)
  const badSortedData = data
    .filter(item => item.name.includes(filter))
    .sort((a, b) => a[sortKey] - b[sortKey]);

  // ✅ Only recalculates when data, filter, or sortKey changes
  const sortedData = useMemo(() => {
    console.log('Expensive sort calculation');
    return data
      .filter(item => item.name.includes(filter))
      .sort((a, b) => a[sortKey] - b[sortKey]);
  }, [data, filter, sortKey]);

  // ✅ Stable object reference for props
  const tableConfig = useMemo(() => ({
    rowHeight: 50,
    columnWidth: 100,
    virtualization: true
  }), []); // Empty deps - config never changes

  return <Table data={sortedData} config={tableConfig} />;
}

// Advanced: Memoize complex selector
function useFilteredItems(items, filters) {
  return useMemo(() => {
    return items.filter(item => {
      return Object.entries(filters).every(([key, value]) => {
        if (!value) return true; // Skip empty filters
        return item[key]?.toString().toLowerCase()
          .includes(value.toLowerCase());
      });
    });
  }, [items, filters]); // Recalc when items or filters change
}
When to use useMemo:
  • Expensive calculations - Heavy array operations, complex math, parsing
  • Reference equality matters - Object/array passed to memoized component or as effect dependency
  • Don't optimize prematurely - Profile first, then optimize bottlenecks
  • Avoid for cheap operations - useMemo overhead can exceed benefit

5. Context Performance and Re-render Optimization

Technique Implementation Benefit Trade-off
Split contexts Separate frequently/infrequently changing data into different contexts Components subscribe only to relevant data More context providers to manage
Memoize context value useMemo(() => ({state, actions}), [state]) Prevents context consumers re-rendering on every provider render Must remember to memoize and manage deps
Context selectors Custom hook to select subset of context Component only re-renders when selected value changes Requires additional abstraction layer
Component composition Pass children before context provider Children don't re-render on context changes Limited to components that don't need context

❌ Bad: All consumers re-render

const AppContext = createContext();

function Provider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  
  // New object every render!
  const value = {
    user, setUser,
    theme, setTheme
  };
  
  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

// All consumers re-render even if
// they only use theme or only use user

✅ Good: Split contexts

const UserContext = createContext();
const ThemeContext = createContext();

function Provider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  
  const userValue = useMemo(
    () => ({ user, setUser }), 
    [user]
  );
  
  const themeValue = useMemo(
    () => ({ theme, setTheme }),
    [theme]
  );
  
  return (
    <UserContext.Provider value={userValue}>
      <ThemeContext.Provider value={themeValue}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Example: Context selector pattern for fine-grained subscriptions

import { createContext, useContext, useRef, useSyncExternalStore } from 'react';

// Create store outside React
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (newState) => {
      state = { ...state, ...newState };
      listeners.forEach(listener => listener());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    }
  };
}

const StoreContext = createContext(null);

// Provider component
function StoreProvider({ children }) {
  const storeRef = useRef();
  if (!storeRef.current) {
    storeRef.current = createStore({ count: 0, user: null, theme: 'light' });
  }

  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
}

// Selector hook - only re-renders when selected value changes
function useStoreSelector(selector) {
  const store = useContext(StoreContext);
  
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getState())
  );
}

// Usage - component only re-renders when count changes, not theme or user
function Counter() {
  const count = useStoreSelector(state => state.count);
  const store = useContext(StoreContext);
  
  return (
    <button onClick={() => store.setState({ count: count + 1 })}>
      {count}
    </button>
  );
}
Context Performance Warning: Every component using useContext re-renders when context value changes, even if using memo. Split contexts by update frequency and use selectors for fine-grained subscriptions. Consider state management libraries (Zustand, Jotai) for complex scenarios.

6. State Update Batching and flushSync Usage

Concept Behavior React Version Use Case
Automatic batching React 18 Multiple setState calls batched automatically in all contexts React 18+ Default behavior - optimal for most cases
Legacy batching Only batched in React event handlers (not timeouts, promises) React 17 and earlier Manual batching needed for async operations
flushSync() Forces synchronous DOM update immediately All versions DOM measurements, third-party integrations
unstable_batchedUpdates Manual batching for React 17 React 17 (deprecated in 18) Legacy apps - not needed in React 18

Example: Automatic batching in React 18

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  console.log('Render'); // How many times does this log?

  // React 17: 3 renders (event handler, then 2 more in timeout)
  // React 18: 2 renders (batched in both event handler AND timeout)
  function handleClick() {
    setCount(c => c + 1); // Batched
    setFlag(f => !f);     // Batched
    // Only 1 re-render for above updates

    setTimeout(() => {
      setCount(c => c + 1); // React 18: Batched ✅
      setFlag(f => !f);     // React 18: Batched ✅
      // React 18: Only 1 re-render
      // React 17: 2 re-renders (not batched in timeout)
    }, 0);
  }

  return <button onClick={handleClick}>Click</button>;
}

// React 18 batches in:
// - Event handlers ✅
// - setTimeout/setInterval ✅
// - Promise.then callbacks ✅
// - Native event handlers ✅
// - Any async code ✅

Example: flushSync for synchronous updates

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

function ScrollExample() {
  const [items, setItems] = useState([1, 2, 3]);
  const listRef = useRef(null);

  function handleAdd() {
    // ❌ Without flushSync - scroll position won't be accurate
    setItems([...items, items.length + 1]);
    listRef.current.scrollTop = listRef.current.scrollHeight;
    // DOM hasn't updated yet, so scrollHeight is old value

    // ✅ With flushSync - forces immediate DOM update
    flushSync(() => {
      setItems([...items, items.length + 1]);
    });
    // Now DOM is updated, scrollHeight is correct
    listRef.current.scrollTop = listRef.current.scrollHeight;
  }

  return (
    <div ref={listRef} style={{ height: 200, overflow: 'auto' }}>
      {items.map(i => <div key={i}>Item {i}</div>)}
      <button onClick={handleAdd}>Add Item</button>
    </div>
  );
}

// Common flushSync use cases:
// 1. DOM measurements (offsetHeight, scrollHeight, getBoundingClientRect)
// 2. Focus management (element.focus())
// 3. Third-party library integration (chart libraries, D3)
// 4. Print dialogs (window.print())
// 5. Selection APIs (window.getSelection())
flushSync Performance Warning: flushSync disables batching and forces synchronous rendering, which hurts performance. Only use when you need immediate DOM updates for measurements or third-party integrations. Overuse can cause performance degradation.
React 17 Manual Batching (Legacy):
import { unstable_batchedUpdates } from 'react-dom';

// React 17 only
setTimeout(() => {
  unstable_batchedUpdates(() => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // Now batched in React 17
  });
}, 0);
Opt-out of Batching (React 18):
import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  }); // Render immediately
  
  flushSync(() => {
    setFlag(f => !f);
  }); // Render immediately again
  
  // 2 separate renders (not batched)
}

Performance Optimization Summary:

  • React.memo - Prevent re-renders for pure components with stable props
  • useCallback - Memoize callbacks passed to memoized children or effect deps
  • useMemo - Cache expensive calculations and maintain reference equality
  • Split state - Separate independent state for granular re-renders
  • Collocate state - Keep state close to where it's used
  • Split contexts - Separate fast/slow changing data into different contexts
  • Context selectors - Subscribe to subset of context for fine-grained updates
  • Automatic batching - React 18 batches all updates automatically
  • flushSync sparingly - Only for DOM measurements and third-party integrations
  • Profile first - Use React DevTools Profiler before optimizing