React Context API - how it works and it's limitations

June 9, 2025

In this post, I’ll explain how React’s Context API works under the hood, how it compares to a state management library like Zustand, and what that means for your app’s performance.

ReactState ManagementZustandPerformanceContext API

First , come context 🙃

The motivation for this article came from something I’ve observed time and again—both junior and senior developers (myself included) often misunderstand the primary purpose of React’s Context API. It’s frequently mistaken for a built-in global state management solution, even though that’s not what it was designed for. I’ve even seen job postings on LinkedIn stating that applicants should know how to use “Context as global state management or 3rd-party state management libraries.”

In my case, I encountered this issue while working at a previous company, where I was tasked to improve the performance of our React Native app—especially on Android, since most of our users were college students using low-end devices. While we made a lot of performance improvements (this was before we implemented React Native's new architecture), I noticed the app was still re-rendering more than necessary. After some investigation, I realised the culprit was our misuse of React Context for global state management.

To be fair, the global state stored in Context wasn’t extremely complex. But it included everything from user data and profile info to UI state, server-synced collections, and notifications—all of which added up. Although the app didn’t totally break, this misuse could potentially affect the apps performance in the future as the scope and complexity of the global state becomes greater. Since it did not affect the app a lot this got de prioritised and kept in the backlog. However, after that company shut down, I had time to dive deep into how Context really works, its limitations, and when it’s better to use dedicated state management libraries like Zustand. This article is a summary of what I learned—written both for myself and for anyone else who might be tempted to treat Context as a catch-all solution for global state.

Before diving into the (simplified) inner workings of Context and Zustand, here’s a quick demo that compares how the two behave. In this demo, you'll see that updating state inside a React Context causes all components consuming that context to re-render—even if they don’t use the updated value. Zustand, on the other hand, avoids these unnecessary re-renders.

This difference comes down to how each approach handles subscriptions and updates under the hood. While it’s technically possible to optimise Context—for example, by splitting it into smaller pieces, using reducers etc—these solutions add complexity quickly. This excellent blog post with examples breaks that down well, and as you can conclude in the article, a dedicated library like Zustand is simply a more efficient and scalable choice.

Context - the inner workings

To understand how React Context works under the hood, it’s helpful to first grasp the basics of React Fiber. Introduced in React 16, Fiber replaced the older stack-based reconciliation algorithm. Reconciliation is the process React uses to determine what parts of the UI need updating when state changes.

Think of your UI as a tree structure, where each node represents an element or component, such as <div><p>, or custom React components. Since React treats the UI as a function of state (UI = f(state)), any change in state can trigger a re-render.

However, React doesn’t update the real DOM immediately. Instead, it uses a Virtual DOM—an internal representation of the UI made up of React elements and components. React maintains two versions of this tree: the previous and the current. It then compares (or “diffs”) these trees during the reconciliation phase and commits only the minimal changes necessary to the actual DOM. This process improves performance by avoiding unnecessary DOM operations.

Underneath, React’s virtual DOM tree is made up of Fiber nodes. A Fiber is a JavaScript object that represents a unit of work during rendering. Each Fiber node has properties like tag (which indicates its type—e.g., host component, context provider, lazy component), links to its parent, child, and sibling Fibers to form the tree structure and other dependencies such as Context etc

React maintains two Fibre trees , the current tree - which resembles what the user currently sees on the screen and the workInProgress tree on which React makes changes. Once the changes are made , it then swaps the pointers of these trees such that the workInProgress tree now becomes the new current tree and the current tree becomes the new workInProgress tree for React to make changes to.

React’s Fiber architecture allows the rendering work to be broken into smaller chunks that can be paused, resumed, or prioritised. This enables React to handle rendering asynchronously and efficiently, which is fundamental for features like Concurrent Mode. For more detailed info on how React Fibre works check out this video which explains it in simple terms.

React processes each Fiber node using the beginWork() & completeWork() methods. As it reaches a Fiber node it will call the beginWork() method and keeps propagating to the child node until it reaches node without a child. Once it reaches this terminal node , it processes it and calls the completeWork() . After the tree has been processed it commits to the DOM.

Now that we know how Fibre works , lets dive into how Context works: When you call React.createContext(defaultValue), React returns an object, some of the properties of this objects are :

interface ReactContext<T> {
  Provider: ReactProvider<T>;
  Consumer: ReactContext<T>;
  _currentValue: T;
  ...
}

Here Provider and Consumer are React components while _currentValue is used for tracking the context internally by React.

Similiarly every React component becomes a Fiber node. The following properties are some of the info contained within the Fiber object:

interface FiberNode {
  ...
  return: FiberNode | null; // parent in the fiber tree
  child: FiberNode | null;
  sibling: FiberNode | null;

  // Context tracking
  dependencies: ContextDependencies | null;

  // Hook state (includes useContext)
  memoizedState: Hook | null;

  // Scheduling fields
  lanes: Lanes;
  childLanes: Lanes;

  alternate: FiberNode | null; // points to the correspoding Fiber node in the other tree dor reconsilliation purposes
}
  • memoizedState This is a linked list of Hook objects. Each call to a hook like useContext, useState etc created an entry here. When useContext(MyContext) is called, memoizedState holds the context value read from MyContext._currentValue
  • dependencies If the component uses any context (via useContext or <Consumer>), the dependencies field is set:
interface ContextDependencies {
  lanes: Lanes; // Tracks which priorities are affected
  firstContext: ContextDependency<any> | null;
}

where lanes (lanes & child lanes) is a scheduling system used by react to :

  • batch work with similar priorities
  • Interrupt low priority renders
  • Keep track of which parts of the tree need to be updated and when

and type ContextDependency is

type ContextDependency<T> = {
  context: ReactContext<T>,
  memoizedValue: T,
  next: ContextDependency<any> | null,
}

where next helps form a linkedList of all the contexts that the given component depends on.

Working with an example

Now that we know the structure of the Fiber nodes , lets work on an example. Consider the following code:

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

function Child() {
  const theme = useContext(MyContext);
  return <div>{ theme } < /div>;
}

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <MyContext.Provider value= { theme } >
    <Child />
    < /MyContext.Provider>
  );
}

Here's what happens under the hood:

  1. Initial Render
    When Child calls useContext(MyContext), React:
    - Reads MyContext._currentValue
    - Stores the value in the component's memoizedState
    - Adds MyContext to the component's fiber.dependecies.firstContext
    This allows React to track which components depend on which contexts during reconciliation
  2. Updating Context
    When the context value changes (eg: from 'light' to 'dark'), React:
    - Updates MyContext._currentValue
    - Starts traversing the Fiber tree
    - For each Fiber, checks if it has a dependencies list that includes MyContext
    - If yes, React schedules that component to re-render by marking its lane and bubbling that info up the tree (using childLanes)
  3. Reconciliation
    - React compares the new and old context values (via the memoized context values in the dependencies list).
    - If the value has changed, the Fiber is marked as needing an update, and the component is re-rendered in the next render cycle.

Now let's take a look at a 3rd party State Management Library. For simplicity sake lets check out Zustand

Zustand - the inner workings

Zustand does not use React Context or React’s Fiber reconciliation system to propagate state updates. Instead, it uses a combination of:

  • A vanilla JavaScript store
  • Selectors and subscriptions
  • Shallow comparison to prevent unnecessary re-renders

Lets take a look at a very simplified implementation of Zustand:

function createStore(createState) {
  let listeners = new Set();
  let state;
  let initialState;

  const setState = (partial) => {
    const nextState = typeof partial === "function" ? partial(state) : partial;

    if (!Object.is(nextState, state)) {
      const previousState = state;
      state = Object.assign({}, state, nextState);
      listeners.forEach((listener) => listener(state, previousState));
    }
  };

  const getState = () => state;
  const getInitialState = () => initialState;

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const api = { setState, getState, getInitialState, subscribe };
  initialState = state = createState(setState, getState, api);

  return api;
}

function useStore(api, selector = (state) => state) {
  return useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState())
  );
}

Lets break it down.

Key concepts

listeners: A Set of functions (from React) that will be notified whenever the store changes.

setState: Merges new state and notifies listeners if state changed.

subscribe: Registers a listener; returns a cleanup function.

useStore: Connects your component to a specific slice of the store using a selector.

Working with an example

Consider the following code:

import create from 'zustand';

const useCounterStore = create((set) => ({
  // ... some other state values
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

function Counter() {
  const count = useCounterStore((state) => state.count); // 🔄 subscribes to count
  return <div>{count}</div>;
}

Step by step breakdown

Try to compare the steps with the simplified zustand source code

Step 1: Component subscribes

When <Counter/> renders

const count = useCounterStore((state) => state.count);

Internally, Zustand calls useSyncExternalStore() like so:

useSyncExternalStore(
  api.subscribe,                // Adds a listener to the store
  () => selector(api.getState()), // Returns selected value from state
);

The component’s selector here is state.count. Zustand stores this as part of the listener React provides.

Step 2: State Update with setState

When the user clicks a button like:

useCounterStore.getState().increment();

This calls:

set((state) => ({ count: state.count + 1 }));

Inside setState() :

const nextState = { count: 1 };
state = { ...state, ...nextState };
listeners.forEach((listener) => listener(state, previousState));

All the listeners are notified

Step 3: React checks If Component needs re-rendering

Each listener React registered with Zustand does this:

if (selector(previousState) !== selector(currentState)) {
  // Rerender the component
}

In our case, selector = state => state.count .

So:

  • For <Counter /> , the selectors returns a different value (0-> 1) -> ✅ Re-render
  • For a component subscribed to some other state.[something_else] returns the same value -> ❌ no re-render.

As you can see the useSyncExternalStore is one of the main parts behind the working of subscribing to changes in this example. To learn how it works watch this video where it is used to subscribe to values from a browser API.

To conclude, understanding how React Context and state management libraries like Zustand work under the hood reveals why choosing the right tool for the job matters so much for performance and maintainability.

React Context operates within React's Fiber reconciliation system. When a context value changes, React traverses the entire Fiber tree, identifies all components that depend on that context (via their dependencies list), and schedules them all for re-rendering—regardless of whether they actually use the changed portion of the state.

Zustand bypasses React's reconciliation entirely. It uses vanilla JavaScript subscriptions combined with selectors and shallow comparison. Only components whose selected slice of state actually changed will re-render, thanks to useSyncExternalStore's intelligent diffing.

When to Use Each

Use React Context for:

  • Dependency injection (passing down services, configurations)
  • Theming (colors, fonts, design tokens)
  • User preferences (language, accessibility settings)
  • Authentication state (user info that rarely changes)
  • Simple, infrequently changing data that needs to be accessible throughout your component tree

Use Zustand (or similar libraries) for:

  • Complex global state with frequent updates
  • Large state objects where components only need specific slices
  • Performance-critical applications where unnecessary re-renders matter
  • State that changes independently (e.g., UI state, server cache, notifications)
  • When you need fine-grained reactivity