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

Subscribing to State Changes Outside React

Hello! Welcome to the final lesson in our module on Performance Optimization and Selectors.

In our last session, we mastered how to use reselect to create memoized selectors. This was a powerful technique for optimizing expensive computations and preventing them from running unnecessarily when unrelated state changes. Our focus was on what happens inside your React components.

Today, we shift our perspective to what happens outside of the React component lifecycle. This lesson addresses the learning outcome: Use the subscribe function to react to state changes outside of React components, for purposes like analytics or syncing with external libraries.

We'll explore how to tap into the "vanilla" JavaScript API of your Zustand store to:

  • Listen for state changes from anywhere in your application.
  • Implement common side effects like logging, sending analytics, or persisting state to web storage.
  • Optimize these subscriptions to only listen for the specific changes you care about.

This capability is what makes Zustand a truly versatile tool, not just a state management library for React.

1. Beyond the Hook: The Vanilla Store API

So far, we've interacted with our store almost exclusively through the useStore hook. However, the create function gives you back an object that is much more than just a hook. It's a complete store object with its own set of utility methods that can be called from anywhere in your JavaScript code, completely independent of React.

The official Zustand documentation provides the clearest and most concise overview of this API.

pmndrs/zustand: Reading/writing state and reacting to changes outside of components

Let's look at the official documentation on the Zustand GitHub repository. This section demonstrates the core methods available on your store object for non-reactive access.

Read the section titled 'Reading/writing state and reacting to changes outside of components'. Focus on the three key actions demonstrated: getState(), subscribe(), and setState(). Notice how they are called directly on the store hook (useDogStore.getState()).

As you saw, you have direct, imperative access to your store:

  • store.getState(): Synchronously returns a snapshot of the current state.
  • store.setState(): Imperatively updates the state, which will in turn trigger updates in any subscribed components.
  • store.subscribe(): Registers a callback function to be executed whenever the state changes.

Today, our focus is on subscribe.

2. The subscribe Function: Listening for Changes

The subscribe function is your primary tool for creating side effects in response to state changes. When you call it, you provide a listener function that receives the entire new state object every time a change occurs.

A critical feature of subscribe is that it returns an unsubscribe function. In any long-running application, you must call this cleanup function when the listener is no longer needed to prevent memory leaks.

Let's look at a practical guide that illustrates this.

Zustand: A Guide to Scalable State Management

This article, 'Zustand: A Guide to Scalable State Management', provides excellent, practical examples of using the subscription API.

Read section '4. Subscriptions: Observing State Outside React'. Pay close attention to the syntax for both full-state and selective subscriptions, and especially the note on unsubscribing.

3. Practical Use Cases for subscribe

The learning outcome specifically mentions analytics and syncing with external libraries. Let's implement exactly that.

Use Case 1: Syncing State with localStorage

A common requirement is to persist user settings or application state so it can be restored when the user reloads the page. subscribe is perfect for this.

The article we just reviewed provides a direct example. Let's say we have a userStore and want to save the user object to localStorage whenever it changes.

// In a non-component file, e.g., services/storage-sync.js
import { userStore } from './stores/userStore';

// Subscribe to changes in the user slice of the state
const unsubscribe = userStore.subscribe(
  (state) => state.user, // The selector
  (user) => {            // The listener, receives the selected state
    console.log('User state changed, saving to localStorage...');
    try {
      const serializedUser = JSON.stringify(user);
      localStorage.setItem('user-data', serializedUser);
    } catch (error) {
      console.error('Could not save user to localStorage', error);
    }
  }
);

// If this logic ever needs to be torn down, you would call:
// unsubscribe();

Note: While this is a great demonstration of subscribe, for robust state persistence, Zustand provides the persist middleware, which we will cover in a future lesson. This manual approach is excellent for understanding the underlying mechanism.

Use Case 2: Sending Analytics Events

Imagine you want to track how many times a user adds an item to their cart for analytics. Firing an event on every single addition could overwhelm your analytics service, especially if the user is clicking rapidly. A better approach is to "debounce" the event. We can use subscribe combined with a debounce utility (like one from the lodash library) to achieve this.

The same article provides a great pattern for this.

Zustand: A Guide to Scalable State Management

Let's revisit the 'Zustand: A Guide to Scalable State Management' article for another practical example.

Read subsection '7.3. Debounced Subscriptions'. This demonstrates how to wrap your subscription listener in a debounce function to control how frequently your side effect runs.

Here's how you might apply it to a shopping cart:

// In a non-component file, e.g., services/analytics.js
import { cartStore } from './stores/cartStore';
import debounce from 'lodash.debounce'; // You'd need to `npm install lodash.debounce`

// Define the function that sends the analytics event
function trackCartChange(itemCount) {
  console.log(`ANALYTICS: Cart count is now ${itemCount}.`);
  // In a real app, this would call your analytics service API
  // e.g., analytics.track('cart_updated', { count: itemCount });
}

// Create a debounced version of the tracking function that waits 1 second
const debouncedTracker = debounce(trackCartChange, 1000);

// Subscribe to changes in the number of items in the cart
cartStore.subscribe(
  (state) => state.items.length,
  (itemCount) => {
    debouncedTracker(itemCount);
  }
);

Now, no matter how quickly the user adds or removes items, the analytics event will only be sent once per second, which is much more efficient.

4. Selective Subscriptions with subscribeWithSelector

In the examples above, we used a version of subscribe that takes a selector: subscribe(selector, listener). This is far more efficient than the basic subscribe(listener) because it only invokes your listener when the specific slice of state you selected has changed.

To enable this more powerful signature, you must wrap your store's creator function with the subscribeWithSelector middleware.

This video provides a clear, step-by-step demonstration of setting it up and using it.

React Zustand Tutorial - 7. Subscribe and subscribeWithSelector Middleware (typescript)

This video from the 'React Zustand Tutorial' series by Conny Gu clearly demonstrates how to implement and use the subscribeWithSelector middleware.

Watch from 11:04 to 17:19. The first part (until 15:11) shows how to add the middleware and use the new subscribe signature with a selector and a listener. The second part (from 15:11) explains the useful fireImmediately option.

To summarize the key points from the video and documentation:

  1. Add the Middleware:

    import { create } from 'zustand';
    import { subscribeWithSelector } from 'zustand/middleware';
    
    const useStore = create(
      subscribeWithSelector((set) => ({
        // ... your state and actions
        fish: 0,
      }))
    );
    
  2. Use the Selective Signature:
    The subscribe method now accepts a selector as the first argument. The listener function will receive the new and previous values of the selected state.

    useStore.subscribe(
      (state) => state.fish, // Selector
      (currentFish, previousFish) => { // Listener
        console.log(`Fish count changed from ${previousFish} to ${currentFish}`);
      }
    );
    
  3. Options: You can pass a third argument, an options object, with properties like:

    • equalityFn: A function to compare the old and new selected state (defaults to Object.is). You can use shallow here, just as with the hook.
    • fireImmediately: A boolean. If true, the listener is executed immediately upon subscription with the current state value.

5. Bonus: subscribe for In-Component Optimization

While today's goal is about using subscribe outside of React, it's worth noting that it also has a powerful use case inside components for performance optimization.

Sometimes, you need to react to a state change but don't want to trigger a full re-render (e.g., to update a non-React library like a charting engine, or to perform a small, direct DOM manipulation). You can use subscribe inside a useEffect hook to achieve this. The useEffect cleanup function is the perfect place to call unsubscribe.

React Zustand Tutorial - 7. Subscribe and subscribeWithSelector Middleware (typescript)

Let's watch an earlier part of the same video, which demonstrates this exact pattern.

Watch from 05:40 to 11:04. This segment shows how to correctly use subscribe within useEffect, including the crucial cleanup step. It then demonstrates a practical example of updating a component's local state in response to a subscription, avoiding a full re-render on every single store change.

This "transient update" pattern is the ultimate escape hatch for performance, allowing you to bypass React's rendering flow entirely when needed.

Conclusion

You've now unlocked the full potential of your Zustand store by learning to interact with it outside of the React component tree. This is a key differentiator from context-based state managers and allows for clean separation of concerns.

Key Takeaways:

  • Zustand stores expose a "vanilla" API (getState, setState, subscribe) that works anywhere.
  • subscribe is the primary tool for creating side effects like logging, analytics, or syncing with external systems in response to state changes.
  • It is crucial to call the unsubscribe function returned by subscribe to prevent memory leaks.
  • The subscribeWithSelector middleware enables efficient, selective subscriptions that only fire when specific state slices change.
  • subscribe can also be used within useEffect as a performance optimization to react to state without causing a component re-render.

This lesson concludes our module on performance and selectors. In our next module, we will venture into a new and important area: Zustand with Server-Side Rendering (SSR). We'll see how the concepts of singleton stores and vanilla APIs become critically important in a server environment.

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

Sign up