Zustand Guidelines

Zustand is a lightweight state management library that provides a simple yet powerful way to manage application state in React. One of its key advantages over useState and React.Context is the ability to hold related properties together while still allowing components to subscribe to very granular changes, thus controlling when components re-render.

Atomic Selectors

The most important concept when using Zustand effectively is atomic selectors. Instead of selecting the entire store or large parts of it, always select only the minimal slice of state that a component actually needs.

Basic Example

// ❌ Bad - component re-renders on ANY store change
const state = useStore();

// ✅ Good - component only re-renders when userName changes
const userName = useStore((store) => store.userName);

Subscribing to Derived Values

Zustand allows you to subscribe to any derived value from the store, not just direct properties. The component will only re-render when the result of the selector function changes, not when the underlying data changes arbitrarily.

Array Length Example

// ❌ Bad - component re-renders whenever myArray changes (even if length stays the same)
const myArray = useStore((store) => store.myArray);
const length = myArray.length;

// ✅ Good - component only re-renders when array length changes
const length = useStore((store) => store.myArray.length);

Element Presence Example

// ❌ Bad - re-renders whenever myArray or myElement changes
const myArray = useStore((store) => store.myArray);
const isPresent = myArray.includes(myElement);

// ✅ Good - only re-renders when the boolean result changes
const isPresent = useStore((store) => store.myArray.includes(myElement));

Performance Optimization Pattern: ID-Based Lists

A powerful pattern for optimizing virtualized tables or lists is to pass only IDs to container components, then have each row/cell fetch its full data directly from the store using atomic selectors.

Before (Inefficient)

// Container passes full objects
const items = useStore((store) => store.items);
return <List items={items} />;

// Inside List component, any change to ANY item causes ALL rows to shallow re-render
// because the items array reference changes (arrays are immutable in React)

After (Optimized)

// Container passes only IDs
const itemIds = useStore((store) => store.items.map((item) => item.id));
return <List itemIds={itemIds} />;

// Inside each Row component
const Row = ({ itemId }: { itemId: string }) => {
  // Each row subscribes to its own item via atomic selector
  const item = useStore((store) => store.items.find((i) => i.id === itemId));
  return <div>{item.name}</div>;
};

Benefits:

  • When ONE property on ONE object changes, the IDs array doesn't change
  • Only the specific Row that needs to update will re-render
  • Other rows remain completely unaffected (no shallow re-render)

Subscribing to Computed State

You can subscribe to any computed/derived state from the store:

// Subscribe to filtered/sorted data
const activeUsers = useStore((store) =>
  store.users.filter((user) => user.isActive),
);

// Subscribe to aggregated data
const totalPrice = useStore((store) =>
  store.cartItems.reduce((sum, item) => sum + item.price, 0),
);

// Subscribe to existence check
const hasUnsavedChanges = useStore(
  (store) => store.currentVersion !== store.savedVersion,
);

Best Practices

  1. Select Minimally: Always select the smallest piece of state needed. If you only need one property, select only that property.

  2. Use Atomic Selectors: Avoid selecting large objects or arrays when you only need a derived value from them.

  3. Avoid Shallow Equality: Don't use shallow comparison helpers. Instead, rely on atomic selectors to prevent unnecessary re-renders.

  4. Co-locate Selectors: For complex selectors used in multiple places, consider creating reusable selector functions:

// selectors.ts
export const selectActiveUserCount = (store: Store) =>
  store.users.filter((u) => u.isActive).length;

// component.tsx
const activeUserCount = useStore(selectActiveUserCount);
  1. Separate Concerns: Use multiple atomic selectors in a component rather than one selector that returns multiple values:
// ❌ Avoid - component re-renders when either value changes
const { userName, userAge } = useStore((store) => ({
  userName: store.user.name,
  userAge: store.user.age,
}));

// ✅ Preferred - component only re-renders when the specific value it needs changes
const userName = useStore((store) => store.user.name);
const userAge = useStore((store) => store.user.age);

Note from Moritz: While i do agree that the second approach is probably cleaner i don't really see how it makes a difference as far as re-renders are concerned..

  1. Memoize Complex Selectors: If your selector performs expensive computations, the component itself should memoize the result:
const expensiveResult = useStore((store) => {
  // expensive computation
  return store.data.map(...).filter(...).reduce(...);
});

// Memoize in component if needed
const memoizedResult = useMemo(() => expensiveResult, [expensiveResult]);

Further Reading