Skip to content
C Codeloom
JavaScript

React Context API Guide

Learn how to use React's Context API to share state across your component tree without prop drilling — with patterns, performance tips, and common mistakes to avoid.

·4 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • When Context is the right tool
  • Creating providers and consumers
  • Splitting contexts for performance
  • Combining Context with useReducer
  • Pitfalls that cause unnecessary rerenders

Prerequisites

  • Familiarity with JavaScript basics

The Context API in React lets you share values across a component tree without manually threading props through every level. It is the built-in answer to prop drilling. This tutorial covers when to reach for Context, how to use it well, and what to watch out for.

Why Context Exists

Imagine a deeply nested button that needs the current theme. Without Context, you would pass theme as a prop through every component between the root and the button. With Context, the theme is defined once at the top and any descendant can read it.

Context is best for values that rarely change but many components need — themes, locale, current user, or feature flags. It is not a replacement for general state management.

Creating a Context

You create a context with createContext. The argument is the default value used when there is no matching provider above in the tree.

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext('light');

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

Wrapping useContext in a custom hook gives you a clean API and a single place to throw errors if the hook is used outside the provider.

Consuming the Context

Any descendant of the provider can read the value with useContext. There is no prop drilling and no intermediate component knows the context exists.

function ThemedButton() {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Theme: {theme}
    </button>
  );
}

The button rerenders whenever the provider value changes. That last word — changes — has a sharp edge that we will look at next.

The Rerender Trap

Every consumer of a context rerenders whenever the provider’s value prop changes by reference. If you pass an inline object literal, you create a new reference on every render.

// Bad: new object every render
<ThemeContext.Provider value={{ theme, setTheme }}>

Wrap the value in useMemo to stabilize the reference and avoid waterfalls of pointless rerenders.

const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

This single change can transform performance in apps with many context consumers.

Splitting Contexts

A common pattern is to split one context into two — one for the value, one for the setter. Components that only need the setter never rerender when the value changes.

const ThemeStateContext = createContext('light');
const ThemeSetterContext = createContext(() => {});

This adds boilerplate but pays off when consumers are expensive to rerender, like virtualized lists.

Combining with useReducer

For more structured state, pair Context with useReducer. The provider exposes state and dispatch, and components dispatch actions instead of calling setters.

function appReducer(state, action) {
  switch (action.type) {
    case 'login':  return { ...state, user: action.user };
    case 'logout': return { ...state, user: null };
    default: return state;
  }
}

export function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, { user: null });
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

Dispatch never changes reference, so consumers of DispatchContext rerender only when state really changes.

When Not to Use Context

Context is not always the right tool. If your value changes frequently — say, mouse position or scroll offset — every consumer will rerender constantly. Use a state library like Zustand or Jotai, or pass props directly.

Context also is not a global event bus. Avoid stuffing unrelated values into one big context just because it is convenient.

Wrapping Up

The Context API is a clean solution to prop drilling for values that change rarely. Memoize the provider value, split contexts when needed, and reach for a state library when frequency outgrows what Context can handle. Used wisely, Context keeps your component tree simple and shallow.