Skip to main content
  1. Posts/

Why Zustand has useShallow (and how it prevents unnecessary renders)

·3 mins

Zustand is clean, flexible, and composable. But like many “unopinionated” libraries, it also gives you enough rope to shoot yourself in the foot.

One classic footgun? Selecting multiple store values as an object in useStore — and getting re-renders even when nothing changed.

This post walks through why that happens, how to reproduce it, how useShallow solves it, and what happens when selecting objects.


The problem: object selectors are recreated every render #

Let’s say you have a Zustand store like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { create } from 'zustand'

type State = {
  count: number
  user: { name: string, email: string }
  increment: () => void
}

export const useStore = create<State>((set) => ({
  count: 0,
  user: { name: 'Marco', email: 'marco@example.com' },
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

And a component that selects both count and user:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { useStore } from './store'

export default function Example() {
  console.log('rendered')

  const { count, user } = useStore((state) => ({
    count: state.count,
    user: state.user,
  }))

  return (
    <div>
      <p>Count: {count}</p>
      <p>User: {user.name}</p>
      <button onClick={() => useStore.getState().increment()}>
        Increment
      </button>
    </div>
  )
}

Clicking the button updates only count, but because the selector returns a new object every time, Zustand triggers a re-render — even if user didn’t change.


The fix: shallow comparison #

Zustand lets you pass a custom comparison function as a second argument to useStore. The library provides a helper called shallow:

1
2
3
4
5
6
import { shallow } from 'zustand/shallow'

const { count, user } = useStore(
  (state) => ({ count: state.count, user: state.user }),
  shallow
)

Now Zustand will shallow-compare the keys (count, user) and only re-render if one of them changes.


The better fix: useShallow #

As of Zustand v4.4.0, you can use useShallow for a cleaner syntax:

1
2
3
4
5
6
7
8
import { useShallow } from 'zustand/react/shallow'

const { count, user } = useStore(
  useShallow((state) => ({
    count: state.count,
    user: state.user,
  }))
)

Same behavior, less boilerplate.


What if you select an object? #

This works fine:

1
const user = useStore((state) => state.user)

Zustand tracks the reference to user. If the object is replaced, the component re-renders. If it’s mutated in place, it won’t:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ⚠️ This won't trigger a re-render
set((state) => {
  state.user.name = 'John'
  return state
})

// ✅ This will trigger a re-render
set((state) => ({
  user: { ...state.user, name: 'John' }
}))

So: when selecting objects, always ensure you’re replacing them immutably.


Alternative: split selectors #

If you want fine-grained control, you can split the selectors:

1
2
const count = useStore((state) => state.count)
const user = useStore((state) => state.user)

This avoids the object reference problem entirely. Zustand tracks count and user separately. You don’t need shallow in this case.


TL;DR #

  • Zustand compares selector results by reference
  • Returning { count, user } creates a new object each render
  • Use shallow or useShallow to prevent unnecessary re-renders
  • When selecting objects, replace them immutably
  • When in doubt: split selectors or wrap with useShallow
1
2
3
4
5
6
7
8
// Without shallow
useStore((state) => ({ count: state.count, user: state.user }))

// With shallow
useStore((state) => ({ count: state.count, user: state.user }), shallow)

// Even better
useStore(useShallow((state) => ({ count: state.count, user: state.user })))

Stop wasting renders. Use useShallow.