React Context Patterns

Difficulty: Intermediate

React Context provides a way to pass data through the component tree without manually passing props at every level. It solves the prop-drilling problem by letting any descendant component subscribe to a shared value. You create a context with React.createContext, wrap a subtree with a Provider, and consume the value with the useContext hook.

The Provider pattern pairs a Context with a dedicated wrapper component that encapsulates both the state and the logic for updating it. Instead of exposing raw setState, you expose semantic actions (login, logout, toggleTheme) through the context value. Consumers only know about the high-level API, not the implementation details.

A common best practice is to split context into two - one for the state and one for the dispatch/actions. This prevents components that only call actions from re-rendering when the state changes. Similarly, memoizing the context value object with useMemo avoids unnecessary renders caused by a new object reference on every parent render.

Context is ideal for low-frequency, globally relevant data: theme, locale, authenticated user, feature flags. It is NOT a replacement for a state management library when you have rapidly changing data (e.g., form input on every keystroke) or complex derived state. In those cases, every consumer re-renders whenever the context value changes, which can tank performance.

Know when not to reach for Context. If only a small subtree needs the data, lifting state or component composition (passing components as props) is simpler. If the data changes frequently or many independent slices exist, a library like Zustand with selectors will give you finer-grained control over re-renders.

Code examples

Basic Context with Provider pattern

const ThemeContext = React.createContext('light');

function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState('light');
  const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

function ThemedButton() {
  const { theme, toggle } = React.useContext(ThemeContext);
  return (
    <button
      onClick={toggle}
      style={{ background: theme === 'dark' ? '#333' : '#eee' }}
    >
      Current: {theme}
    </button>
  );
}

// Usage
<ThemeProvider>
  <ThemedButton />
</ThemeProvider>

ThemeProvider owns the state and exposes a toggle function. ThemedButton consumes both without knowing about useState.

Split context to reduce re-renders

const StateCtx = React.createContext();
const DispatchCtx = React.createContext();

function AuthProvider({ children }) {
  const [user, setUser] = React.useState(null);

  const actions = React.useMemo(() => ({
    login: (u) => setUser(u),
    logout: () => setUser(null),
  }), []);

  return (
    <StateCtx.Provider value={user}>
      <DispatchCtx.Provider value={actions}>
        {children}
      </DispatchCtx.Provider>
    </StateCtx.Provider>
  );
}

function useAuthState() { return React.useContext(StateCtx); }
function useAuthActions() { return React.useContext(DispatchCtx); }

// LogoutButton only uses actions - never re-renders when user changes
function LogoutButton() {
  const { logout } = useAuthActions();
  return <button onClick={logout}>Log out</button>;
}

Splitting state and dispatch into separate contexts lets action-only consumers skip re-renders. useMemo on the actions object ensures a stable reference.

When NOT to use context - high-frequency updates

// BAD: every keystroke re-renders all consumers
const FormContext = React.createContext();

function FormProvider({ children }) {
  const [values, setValues] = React.useState({ name: '', email: '' });
  return (
    <FormContext.Provider value={{ values, setValues }}>
      {children}
    </FormContext.Provider>
  );
}

// 20 field components all consuming FormContext
// Each keystroke in any field re-renders ALL 20 fields

// BETTER: use Zustand with selectors or keep local state per field
// and only sync on blur/submit.

Context lacks selectors - when the value changes, every useContext subscriber re-renders. For forms with many fields, this causes noticeable lag.

Key points

Concepts covered

React.createContext, Provider pattern, useContext, context composition, when not to use context