Skip to content
Codeloom
React

React Context Performance: Splitting, Memoizing, and Selectors

Solve React Context performance problems with provider splitting, value memoization, selector patterns, and smart composition strategies.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why Context causes unnecessary re-renders
  • Splitting state and dispatch into separate contexts
  • Memoizing context values properly
  • Selector patterns with use-context-selector
  • When to move beyond Context

Prerequisites

  • Basic React knowledge
  • Familiarity with Context API and hooks

React Context is the built-in way to share state across a component tree without prop drilling. It works well for low-frequency updates like themes, locales, and authentication. But when Context holds frequently changing state, every consumer re-renders on every update, even if it only uses a small slice of the value. This tutorial covers the patterns that solve that problem.

The Re-render Problem

When a Context value changes, React re-renders every component that calls useContext for that context. It does not check whether the specific piece of data the component uses has changed.

const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  return (
    <AppContext.Provider value={{ user, theme, notifications, setUser, setTheme, setNotifications }}>
      {children}
    </AppContext.Provider>
  );
}

In this setup, a component that only reads theme will re-render when notifications changes. The larger the context value, the worse this gets.

Pattern 1: Split Your Contexts

The simplest fix is to separate unrelated state into different contexts. Group values by how often they change and which components consume them.

const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);

function AppProviders({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <NotificationContext.Provider value={{ notifications, setNotifications }}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Now a component reading useContext(ThemeContext) will not re-render when notifications change. This pattern covers the majority of Context performance issues.

Pattern 2: Separate State and Dispatch

A common refinement is to split a context into a state context and a dispatch context. Components that only call actions (like buttons that dispatch) never need to re-render when state changes.

const TodoStateContext = createContext();
const TodoDispatchContext = createContext();

function todoReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, { id: Date.now(), text: action.text, done: false }];
    case 'toggle':
      return state.map(t =>
        t.id === action.id ? { ...t, done: !t.done } : t
      );
    case 'delete':
      return state.filter(t => t.id !== action.id);
    default:
      return state;
  }
}

function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <TodoStateContext.Provider value={todos}>
      <TodoDispatchContext.Provider value={dispatch}>
        {children}
      </TodoDispatchContext.Provider>
    </TodoStateContext.Provider>
  );
}

function useTodos() {
  return useContext(TodoStateContext);
}

function useTodoDispatch() {
  return useContext(TodoDispatchContext);
}

The dispatch function from useReducer has a stable identity, so TodoDispatchContext never triggers re-renders. The AddTodo component below only needs dispatch:

function AddTodo() {
  const dispatch = useTodoDispatch();
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    dispatch({ type: 'add', text });
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
}

This component does not re-render when todos are added or toggled.

Pattern 3: Memoize the Context Value

When you pass an object literal as the context value, React creates a new object on every render, causing all consumers to re-render even if the contents are identical. Wrap the value in useMemo.

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    checkAuth().then(u => {
      setUser(u);
      setLoading(false);
    });
  }, []);

  const value = useMemo(
    () => ({ user, loading, setUser }),
    [user, loading]
  );

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

Without useMemo, every render of AuthProvider creates a new value object, even if user and loading have not changed. With useMemo, consumers only re-render when the actual data changes.

Pattern 4: Component Composition Instead of Context

Sometimes the best optimization is to avoid Context entirely. If a value is only needed a few levels down, pass it as a prop or use component composition.

// Instead of putting `user` in context for one deeply nested component:
function Page({ user }) {
  return (
    <Layout>
      <Sidebar>
        <UserAvatar name={user.name} avatar={user.avatar} />
      </Sidebar>
      <Content />
    </Layout>
  );
}

// Or pass the entire rendered component down:
function Page({ user }) {
  const avatar = <UserAvatar name={user.name} avatar={user.avatar} />;
  return <Layout sidebar={avatar} />;
}

This technique is called “lifting content up.” The parent renders the component that needs the data and passes the rendered result down. No Context needed.

Pattern 5: Selector Libraries

For cases where splitting contexts is impractical, selector libraries like use-context-selector let consumers subscribe to specific slices of a context value.

npm install use-context-selector
import { createContext, useContextSelector } from 'use-context-selector';

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState);
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  );
}

// Only re-renders when state.theme changes
function ThemeToggle() {
  const theme = useContextSelector(AppContext, v => v.state.theme);
  const dispatch = useContextSelector(AppContext, v => v.dispatch);

  return (
    <button onClick={() => dispatch({ type: 'toggleTheme' })}>
      Current: {theme}
    </button>
  );
}

The useContextSelector hook compares the selector result between renders and skips re-rendering if it has not changed.

Choosing the Right Pattern

Start with the simplest approach and escalate only when you measure a performance problem.

  1. Split contexts for unrelated state groups. This solves most issues.
  2. Separate state and dispatch when many components only write, never read.
  3. Memoize the value to avoid false re-renders from new object references.
  4. Use composition when data is needed in only a few places.
  5. Use selectors when you have a large, interconnected context that cannot be split.

If none of these patterns are enough, the state has likely outgrown Context. Move to a dedicated state manager like Zustand, Jotai, or Redux Toolkit, which have fine-grained subscription models built in.