DEV Community

Javapixa Creative Studio
Javapixa Creative Studio

Posted on Originally published at blog.javapixa.com

Let's Understand Zustand Selector So That Application Performance Is Optimal

Developing modern web applications often means juggling complex state. When state changes, we want our user interfaces to update efficiently, showing the latest information without stuttering or causing components to re-render more than they truly need to. Unnecessary re-renders are a common performance bottleneck, leading to slower applications and a degraded user experience. This is especially true in React applications where components can inadvertently trigger updates across wide swaths of the component tree.

Zustand, a small, fast, and scalable state management solution, has become a popular choice for many developers. Its simplicity is a breath of fresh air, but even with Zustand's lean architecture, developers can sometimes overlook a crucial aspect of performance optimization its selectors. Understanding and correctly utilizing Zustand selectors is key to ensuring your application remains snappy and responsive as it grows. We will explore what Zustand selectors are, how they work, and most importantly, how to wield them to prevent wasteful re-renders and achieve optimal application performance.

Why Application Performance Matters in State Management

Before diving into the specifics of selectors, let's briefly reinforce why performance in state management is so critical. In a React application, a component re-renders when its props or state change. While React's reconciliation algorithm is highly optimized, an excessive number of re-renders can still lead to performance issues. If a component observes a piece of state from a global store, and that store's state changes, the component will re-render even if the specific part of the state it cares about hasn't actually changed. This cascade of unnecessary updates is what we aim to mitigate.

Consider an application with a large global state object. If one small property in that object updates, every component subscribed to the entire state object will re-render. This wastes CPU cycles, increases memory usage, and ultimately slows down your application. Effective state management isn't just about organizing data it's also about efficiently notifying components of only the changes they need to react to.

Zustand Basics A Quick Refresher

Zustand is built on a simple premise a custom hook useStore that lets your components subscribe to your store. A basic Zustand store looks something like this.

// store.js
import { create } from 'zustand';

const useBearStore = create((set) => ({
  bears count 0,
  addBear aNumber => set(state => ({ bears count state.bears count + aNumber })),
  removeBear () {
    set(state => ({ bears count state.bears count - 1 }));
  },
  user profile null, // Imagine a complex user object here
  fetchUserProfile () {
    // async operations would update user profile
  }
}));

export default useBearStore;
Enter fullscreen mode Exit fullscreen mode

In a component, you might subscribe to the store like so.

// SomeComponent.jsx
import useBearStore from './store';

function BearCounter () {
  const bears = useBearStore(state => state.bears count); // This is a selector!
  return <h1>{bears} bears around here...</h1>;
}

function ProfileDisplay () {
  const userProfile = useBearStore(state => state.user profile); // Another selector!
  return (
    <div>
      {userProfile && <p>Welcome back {userProfile.name}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wait, did we just use a selector? Yes, we did! That function passed to useBearStore is precisely what a Zustand selector is. Often, developers use them without explicitly thinking about the "selector" terminology, but understanding its role is fundamental.

The Problem with Direct useStore Usage Without Thoughtful Selection

While the examples above already use selectors effectively, it's easy to fall into a pattern that leads to performance issues. Let's look at a common mistake.

Imagine a component that only needs to display the number of bears.

// BadBearDisplay.jsx
import useBearStore from './store';

function BadBearDisplay () {
  // This subscribes to the *entire* state object
  const entireState = useBearStore();

  // Later access specific parts
  // console.log(entireState.bears count);
  // console.log(entireState.user profile);

  return (
    <div>
      <p>Bears population: {entireState.bears count}</p>
      {/* ...other parts of the component might not even need entireState.user profile */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In BadBearDisplay, useBearStore() without an argument subscribes the component to all changes in the store. If user profile updates, BadBearDisplay will re-render, even though it only displays bears count and doesn't care about the user profile change. This is the core problem that mindful selector usage solves.

Enter Zustand Selectors The Performance Solution

A Zustand selector is simply a function that takes the entire store state as its argument and returns the specific piece of state that your component needs. The magic of Zustand is that it will only re-render the component if the returned value from your selector changes.

// GoodBearDisplay.jsx
import useBearStore from './store';

function GoodBearDisplay () {
  // This selector returns only the 'bears count' property
  const bears = useBearStore(state => state.bears count);

  return (
    <div>
      <p>Current bears: {bears}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In GoodBearDisplay, the component only re-renders if state.bears count changes. If user profile or any other property in the store updates, GoodBearDisplay remains unaffected and does not re-render. This granular control over subscriptions is the cornerstone of optimizing performance with Zustand.

How Zustand Selectors Prevent Unnecessary Re-renders

The mechanism is straightforward. When you pass a selector function to useStore, Zustand does the following.

  1. Executes the selector It calls your function with the current store state.
  2. Compares the result It takes the value returned by your selector and compares it to the value returned by the previous execution of the same selector.
  3. Triggers re-render if different If the current returned value is different from the previous one (using strict equality comparison ===), Zustand signals your component to re-render. If they are the same, no re-render occurs.

This comparison is crucial. It ensures that components only update when the specific data they are interested in has genuinely changed, not just when some other unrelated part of the global state updates.

The equalityFn Parameter Fine Tuning Your Selectors

Zustand's default comparison uses strict equality (===). This works perfectly for primitive values like numbers, strings, and booleans. However, what happens if your selector returns an object or an array?

// UserDetails.jsx
import useBearStore from './store';

function UserDetails () {
  // This selector returns an object
  const userDetails = useBearStore(state => ({
    name state.user profile.name,
    email state.user profile.email
  }));

  // PROBLEM: userDetails will be a new object on every state update,
  // even if name and email haven't changed, causing unnecessary re-renders.
  return (
    <div>
      <p>Name: {userDetails.name}</p>
      <p>Email: {userDetails.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In UserDetails, even if state.user profile.name and state.user profile.email remain the same, the selector state => ({ name state.user profile.name, email state.user profile.email }) creates a new object on every re-render. Because === compares references for objects, this new object will always be considered "different" from the previous one, leading to unnecessary re-renders.

This is where the optional second argument to useStore comes in the equalityFn. You can provide a custom comparison function. Zustand exports shallow from its vanilla core, which performs a shallow comparison of object properties.

// UserDetailsOptimized.jsx
import useBearStore from './store';
import { shallow } from 'zustand/shallow'; // Import shallow

function UserDetailsOptimized () {
  const userDetails = useBearStore(state => ({
    name state.user profile.name,
    email state.user profile.email
  }), shallow); // Use shallow comparison here

  return (
    <div>
      <p>Name: {userDetails.name}</p>
      <p>Email: {userDetails.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, UserDetailsOptimized will only re-render if userDetails.name or userDetails.email (or both) actually change. The shallow comparison checks if each property in the new object is strictly equal to the corresponding property in the old object. This is a common and highly effective pattern for optimizing selectors that return new objects or arrays.

Practical Examples Implementing Selectors for Optimal Performance

Let's look at more concrete scenarios where selectors shine.

Selecting a primitive value

This is the most basic and common use case.

import useStore from './myStore';

function UserScoreDisplay () {
  const score = useStore(state => state.user.score);
  return <p>Your score is: {score}</p>;
}
Enter fullscreen mode Exit fullscreen mode

UserScoreDisplay only re-renders if state.user.score changes.

Selecting multiple primitive values as an object with shallow

When you need a few properties, but don't want to subscribe to the entire parent object.

import useStore from './myStore';
import { shallow } from 'zustand/shallow';

function ProductInfo () {
  const productDetails = useStore(state => ({
    id state.currentProduct.id,
    name state.currentProduct.name,
    price state.currentProduct.price
  }), shallow);

  return (
    <div>
      <h3>{productDetails.name}</h3>
      <p>ID: {productDetails.id}</p>
      <p>Price: ${productDetails.price}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

ProductInfo only re-renders if id, name, or price from currentProduct change.

Selecting and deriving state

Sometimes, the value you need isn't directly in the state, but can be computed from it. This derived state can also be selected efficiently.

import useStore from './myStore';

function ActiveTasksCount () {
  const activeCount = useStore(state =>
    state.tasks.filter(task => !task.completed).length
  );
  return <p>You have {activeCount} active tasks.</p>;
}
Enter fullscreen mode Exit fullscreen mode

ActiveTasksCount will re-render only if the activeCount derived from the tasks array changes. Be mindful that if state.tasks is a large array and this selector is called frequently, the filter and length operations can be expensive. For very complex or computationally intensive derivations, consider memoization techniques within the selector itself or external libraries.

Common Pitfalls and Best Practices with Zustand Selectors

Even with a clear understanding of selectors, there are nuances to consider.

Creating new objects or arrays without an equalityFn

As discussed, this is the most common mistake. Always use shallow (or a custom deep equality function if needed) when your selector returns a new object or array literal.

Over-selecting or under-selecting

  • Over-selecting Returning too much state (e.g., the entire user object when you only need user.name). This can still lead to unnecessary re-renders if other properties on the user object change. Select only what is absolutely necessary.
  • Under-selecting This is less common, but imagine a selector that returns state.user.firstName and another that returns state.user.lastName. If you need both, it's often better to select them together with shallow to manage component boundaries and props more cleanly.

Complex or computationally expensive selectors

While selectors are powerful, remember they run on every state change notification. If a selector performs complex filtering, mapping, or calculations on large datasets, it can become a bottleneck itself. For these cases, consider memoization.

// Potentially expensive selector
const expensiveSelector = state => {
  console.log('Running expensive selector'); // will log on every state change
  return state.largeDataArray
    .filter(...)
    .map(...)
    .reduce(...);
};

// Use a memoized version for performance
import { createSelector } from 'reselect'; // You'd install reselect

// Define input selectors
const getLargeDataArray = state => state.largeDataArray;
const getFilterCriteria = state => state.filterCriteria;

// Create a memoized selector
const memoizedExpensiveSelector = createSelector(
  [getLargeDataArray, getFilterCriteria],
  (largeDataArray, filterCriteria) => {
    console.log('Running memoized computation'); // only logs if inputs change
    return largeDataArray
      .filter(item => item.criteria === filterCriteria)
      .map(...)
      .reduce(...);
  }
);
Enter fullscreen mode Exit fullscreen mode

While Zustand doesn't provide a built-in memoization utility like reselect, you can easily integrate reselect or similar libraries to create memoized selectors outside your component. Then, use your memoized selector just like any other in your useStore call.

Collocating selectors with components

For simple selectors, defining them directly within the useStore call inside your component is fine and often improves readability. However, for more complex or reusable selectors, it's good practice to define them outside the component. This also makes them easier to test.

// selectors.js
export const selectIsUserLoggedIn = state => !!state.user.authToken;
export const selectUserProfileSummary = (state) => ({
  name state.user.name,
  avatar state.user.avatarUrl
});

// MyComponent.jsx
import useStore from './myStore';
import { selectIsUserLoggedIn, selectUserProfileSummary } from './selectors';
import { shallow } from 'zustand/shallow';

function UserNav () {
  const isLoggedIn = useStore(selectIsUserLoggedIn);
  const userSummary = useStore(selectUserProfileSummary, shallow);

  if (!isLoggedIn) return null;

  return (
    <nav>
      <img src={userSummary.avatar} alt={userSummary.name} />
      <span>Hello, {userSummary.name}</span>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

This approach promotes reusability and ensures the selector function itself is not re-created on every component re-render, though Zustand efficiently handles this for inlined functions as well.

Beyond Selectors Holistic Performance Tips for Zustand

While selectors are powerful, they are part of a larger performance strategy.

  • Immutable Updates Always update your Zustand state immutably. Never directly modify existing state objects or arrays. Instead, create new ones with the desired changes. Zustand relies on reference equality to detect changes, and mutable updates bypass this mechanism, leading to stale UI or hard-to-debug issues.
  • Batching Updates Zustand automatically batches updates within the same event loop tick using queueMicrotask by default. This means if you call set multiple times synchronously, your components will only re-render once. This is a significant advantage for performance, as it prevents intermediate, unnecessary re-renders.
  • Segment Your State Avoid creating a single, giant global state object for everything. Instead, create multiple smaller, focused Zustand stores (e.g., useAuthStore, useCartStore). This naturally reduces the surface area for changes and improves clarity.

Conclusion

Zustand selectors are more than just a way to extract data from your store they are your primary tool for optimizing application performance. By carefully defining what specific pieces of state your components need and leveraging the equalityFn parameter when necessary, we can prevent a cascade of unnecessary re-renders. This leads to snappier UIs, a smoother user experience, and a more maintainable codebase.

As developers, we strive to build performant and robust applications. Mastering Zustand selectors is a fundamental step in achieving that goal with Zustand. Embrace them thoughtfully, understand their underlying mechanics, and watch your application's responsiveness soar.

Top comments (0)