Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Zustand & Context Coexistence Pattern

Hello! Welcome to our next lesson.

In our previous session, we performed a complete, "all-at-once" refactoring of a feature from React Context to a global Zustand store. This was an ideal scenario, demonstrating the end-state of a migration: a flatter component tree, less boilerplate, and optimized re-renders via selectors.

However, real-world migrations are rarely so clean. In a large, active codebase, you'll almost certainly face a transitional period where new components built with Zustand must coexist and interact with legacy components still using React Context.

Today's lesson focuses on navigating this "messy middle." We will address the learning outcome: Design a coexistence pattern allowing components using Zustand to interoperate with components still using React Context during a transitional period.

We will explore a powerful pattern that combines the strengths of both Zustand and React Context, enabling you to build bridges between the old and new parts of your application. This allows for a gradual, safer migration strategy.

The Challenge: Two Separate Worlds

The fundamental challenge is that a global Zustand store and a React Context store are two distinct, isolated sources of truth.

  • A component using useContext(MyContext) is subscribed to the MyContext.Provider.
  • A component using useMyStore() is subscribed to the global Zustand store.

They are unaware of each other. This creates two primary interoperability problems during a migration:

  1. New Zustand components needing legacy Context data: A new, performant component you've built with Zustand might need access to state that still lives in an old React Context (e.g., an authentication token or user ID).
  2. Legacy Context components needing new Zustand data: An existing component, deeply nested and reliant on useContext, might need to display data from a new feature you've built with Zustand (e.g., showing the number of items from a new cartStore).

Simply refactoring one component is not enough; we need a way for these two worlds to communicate.

The Solution: Scoped Stores with the "Zustand-in-Context" Pattern

The key to solving this is to move away from the idea that a Zustand store must be a global singleton. We can create scoped stores that are tied to a specific part of the React component tree. And the tool to achieve this scoping is, ironically, React Context.

The idea is not to use Context for state management, but for what it does best: dependency injection. We will use a Context Provider to pass down a Zustand store instance itself.

This approach has several benefits that are crucial for migration and coexistence:

  • It allows a store to be initialized with props.
  • It isolates state to a specific component subtree, preventing conflicts.
  • It makes testing much simpler.

Let's explore this pattern in detail.

Zustand and React Context

To understand the motivation behind this pattern, let's start with an excellent article by Dominik Dorfmeister (TkDodo), a prominent figure in the React ecosystem. He clearly articulates the limitations of purely global Zustand stores and introduces the Context-based solution.

Please read the first part of the article, from the beginning down to (but not including) the section titled 'React Context'. Focus on the three drawbacks the author identifies for global stores: 'Initializing from Props', 'Testing', and 'Reusability'.

As the article highlights, creating stores outside the React lifecycle has limitations. The solution is to create the store inside a component and provide it via Context.

Let's see this pattern implemented. The following video walks through the exact code, which mirrors the solution in the article you just read.

Zustand with Context API – An Advanced Pattern

This video from Cosden Solutions provides a clear, step-by-step implementation of the 'Zustand-in-Context' pattern. We'll watch the core sections that build the provider and the custom hook.

Please watch from 08:18 to 15:48. The first segment (08:18 - 14:15) shows the creation of the CountProvider. Pay close attention to how createStore (from vanilla Zustand) is used inside a useState initializer function to ensure the store is created only once. The second segment (14:15 - 15:48) demonstrates how to build a custom hook (useCountStore) that consumes the context and uses the useStore hook to access the state.

Let's break down the key pieces of this powerful pattern:

  1. The Store Creator (createStore): Inside a Provider component, we use createStore from zustand. Unlike the create function we used previously, this one doesn't return a hook; it returns the raw store object.
  2. One-Time Initialization (useState): We create the store inside a useState initializer function: useState(() => createStore(...)). This ensures the store is instantiated only on the component's initial render, preventing it from being recreated on every re-render. This is a standard React pattern for expensive initializations.
  3. The Context Provider: A standard React.createContext is used. The Provider component renders <MyContext.Provider value={store}>, passing the entire store instance down the tree.
  4. The Consumer Hook (useStore): A custom hook (e.g., useMyStore) is created. It does two things:
    • It uses useContext to get the store instance from the provider.
    • It passes that store instance and a selector function to the useStore hook from zustand. This is what enables the selective subscription and performance benefits.

This pattern is the modern, recommended approach in the Zustand documentation for scenarios requiring non-global stores. It officially replaces a deprecated createContext utility from older versions of Zustand, as shown in the "Migration" section of the docs (Resource ID 37328).

Applying the Pattern: Building Bridges

Now that we have this flexible pattern, let's use it to solve our two coexistence challenges.

Scenario 1: New Zustand Component Needs Legacy Context Data

This is the more straightforward case. Your new component is a child of the legacy Context.Provider, so it can read from it.

Let's say you have a LegacyUserContext that provides a userId. You are building a new ProfileCard component using the "Zustand-in-Context" pattern because each card needs its own isolated state. You can initialize the card's store using the userId from the legacy context.

import { useContext } from 'react';
import { LegacyUserContext } from './LegacyUserContext';
import { ProfileCardStoreProvider, useProfileCardStore } from './ProfileCardStore';

// The new component that uses its own scoped Zustand store
const ProfileCard = () => {
  const bio = useProfileCardStore(state => state.bio);
  const fetchBio = useProfileCardStore(state => state.actions.fetchBio);

  // ... component logic
};

// A wrapper that bridges the two worlds
const ProfileCardWithData = () => {
  // 1. Read the required data from the legacy context
  const { userId } = useContext(LegacyUserContext);

  // 2. Use that data to initialize the new, scoped Zustand store
  return (
    <ProfileCardStoreProvider initialUserId={userId}>
      <ProfileCard />
    </ProfileCardStoreProvider>
  );
};

Here, ProfileCardStoreProvider would be the component we designed using the "Zustand-in-Context" pattern, accepting initialUserId as a prop to initialize its store. This creates a clean, one-way data flow from the old context to the new component.

Scenario 2: Legacy Context Component Needs New Zustand Data

This is more complex. How do you inject state from a Zustand store into a component that only knows how to call useContext?

We create an Adapter Component. Its sole purpose is to act as a bridge. It subscribes to the Zustand store and uses the data it receives to feed a React Context Provider.

Imagine you've migrated your shopping cart logic to a global useCartStore. However, an old LegacyHeader component expects to get the itemCount from a LegacyAppContext.

import { useCartStore } from './cartStore';
import { LegacyAppContext } from './legacyAppContext';

// The Adapter Component
const CartToContextBridge = ({ children }) => {
  // 1. Subscribe to the new Zustand store
  const itemCount = useCartStore(state => state.items.length);

  // We need to get the old context value to avoid overwriting other data
  const legacyValue = useContext(LegacyAppContext);

  // 2. Provide a new context value that merges the old and new state
  const newContextValue = {
    ...legacyValue,
    cart: { itemCount }, // Shape the data as the legacy component expects
  };

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

// In your application's component tree:
function App() {
  return (
    <LegacyAppProvider> {/* The original provider */}
      {/* ... other components */}
      <CartToContextBridge>
        <LegacyHeader /> {/* This component can now access cart.itemCount via context */}
      </CartToContextBridge>
      {/* ... other components */}
      <NewCheckoutPage /> {/* This component uses useCartStore directly */}
    </LegacyAppProvider>
  );
}

This CartToContextBridge is a powerful, temporary tool. It allows LegacyHeader to function without any modification, reading the new state as if it were old. This isolates the legacy code and allows you to migrate components one by one without breaking the UI. Once LegacyHeader is eventually refactored to use useCartStore directly, you can remove the bridge component.

Conclusion

Migrating a large application is a marathon, not a sprint. Having a robust coexistence strategy is essential for a smooth and safe transition.

Key Takeaways:

  • The Problem: Global Zustand stores and React Context providers create separate, isolated state worlds, making interoperability a challenge during gradual migrations.
  • The Core Pattern: The "Zustand-in-Context" pattern is the solution. It uses React Context for dependency injection to provide a scoped Zustand store instance, allowing for initialization from props and state encapsulation within a component subtree.
  • Bridging from Context to Zustand: A new component can consume a legacy context and use its data to initialize or sync with its own Zustand store.
  • Bridging from Zustand to Context: An "Adapter Component" can be created to subscribe to a Zustand store and provide that data through a React Context Provider for legacy components to consume, requiring no changes to the legacy components themselves.

By mastering these patterns, you can de-risk your migration from React Context to Zustand, enabling you to improve your application's state management architecture piece by piece.

In our final lesson, we will zoom out from the technical implementation to the human side of the process. We will discuss how to outline a rollout plan for introducing Zustand to a development team, covering documentation, code standards, and review guidelines to ensure a successful adoption.

Can't find a good explanation? Sign up and we'll make it for you

Sign up