Zustand is one of the most popular state libraries for React, loved for its tiny API. Version 5.1.0 improves its two most-used middlewares. The persist middleware now works properly with async storage and async migrations, and persist.rehydrate() returns a real Promise you can await. The devtools middleware can now dispatch your store’s actions straight from Redux DevTools. This tutorial walks through both changes with working TypeScript examples, shows how to use them to fix hydration flicker, and covers what to check when you upgrade.
What changed in 5.1.0
From the v5.1.0 release notes:
- The
persistmiddleware now supportsPromiseLikeresults from storage reads, migrations and migration writes. persist.rehydrate()now returns a nativePromise<void>, which you can await to make sure hydration is complete.- The
devtoolsmiddleware supports dispatching store actions from Redux DevTools with a newactionCreatorsoption, including nested actions and arguments.
Upgrade:
npm install zustand@^5.1.0
Part 1: persist with async storage
Browser localStorage is synchronous, but plenty of storage isn’t: IndexedDB wrappers, React Native’s AsyncStorage, encrypted storage, or a server-backed store. Zustand’s createJSONStorage accepts any object with getItem, setItem and removeItem. With 5.1, Promise-returning (or other PromiseLike) reads and migrations are handled properly.
Here’s a cart store persisted to IndexedDB with the small idb-keyval package:
import { create } from 'zustand'
import { persist, createJSONStorage, type StateStorage } from 'zustand/middleware'
import { get, set, del } from 'idb-keyval'
const idbStorage: StateStorage = {
getItem: async (name) => (await get(name)) ?? null,
setItem: async (name, value) => { await set(name, value) },
removeItem: async (name) => { await del(name) },
}
type CartItem = { id: string; qty: number }
type CartState = {
items: CartItem[]
add: (id: string) => void
clear: () => void
}
export const useCart = create<CartState>()(
persist(
(set) => ({
items: [],
add: (id) =>
set((s) => {
const found = s.items.find((i) => i.id === id)
return found
? { items: s.items.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i)) }
: { items: [...s.items, { id, qty: 1 }] }
}),
clear: () => set({ items: [] }),
}),
{
name: 'cart',
storage: createJSONStorage(() => idbStorage),
partialize: (s) => ({ items: s.items }),
},
),
)
partialize keeps the action functions out of storage, so only the data is saved.
Async migrations
When your stored data shape changes, bump version and write a migrate function. In 5.1, migrate can return a Promise. That’s useful when the migration needs to read something else from async storage or call an API:
type CartV0 = { items: string[] } // old shape: an array of ids
persist(stateCreator, {
name: 'cart',
version: 1,
storage: createJSONStorage(() => idbStorage),
migrate: async (persisted, version) => {
if (version === 0) {
const old = persisted as CartV0
return { items: old.items.map((id) => ({ id, qty: 1 })) }
}
return persisted as { items: CartItem[] }
},
})
The release notes say migration writes can be PromiseLike too, so the migrated state is saved back through your async storage before hydration finishes.
Part 2: await rehydrate() to stop hydration flicker
With async storage, the store starts with its initial state (an empty cart) and fills in once the stored data loads. Without care, users briefly see “Your cart is empty”. A common fix is to skip automatic hydration and control it yourself.
Set skipHydration: true in the persist options. Then, because rehydrate() now returns a Promise<void>, you can wait for it cleanly:
import { useEffect, useState } from 'react'
import { useCart } from './cart-store'
export function CartGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(useCart.persist.hasHydrated())
useEffect(() => {
if (ready) return
let cancelled = false
useCart.persist.rehydrate().then(() => {
if (!cancelled) setReady(true)
})
return () => {
cancelled = true
}
}, [ready])
if (!ready) return <p>Loading your cart…</p>
return <>{children}</>
}
This pattern is also handy outside React. For example, in a test you can make sure the store is loaded before asserting:
await useCart.persist.rehydrate()
expect(useCart.getState().items).toHaveLength(2)
SSR frameworks
In Next.js, TanStack Start or React Router apps that render on the server, skipHydration: true plus a client-side rehydrate() call avoids mismatches between server HTML (no stored state) and the first client render. The persist middleware docs recommend skipHydration for SSR, and the awaitable Promise makes the “ready” step easy to get right.
Part 3: dispatch actions from Redux DevTools
The devtools middleware connects a Zustand store to the Redux DevTools browser extension so you can inspect state changes. With 5.1, the new actionCreators option makes store functions appear in the DevTools action dropdown, so you can trigger them by hand while debugging.
This example is adapted from the devtools docs:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
type Store = {
count: number
actions: {
increment: (by: number) => void
reset: () => void
}
}
export const useCounter = create<Store>()(
devtools(
(set) => ({
count: 0,
actions: {
increment: (by) =>
set((s) => ({ count: s.count + by }), undefined, 'increment'),
reset: () => set({ count: 0 }, undefined, 'reset'),
},
}),
{
name: 'CounterStore',
// All functions in `actions` except `reset`
actionCreators: { 'actions.*': true, 'actions.reset': false },
},
),
)
How actionCreators works:
- Keys are dot-notation paths into your state.
*matches one level and**matches any depth. - Values are
trueto include a function,falseto leave it out, or a function to use instead. - In the DevTools dropdown you type each argument as JSON, like
5,"foo"or{ "by": 5 }. Leave it empty forundefined.
You can also dispatch a custom action in JSON, passing arguments as an array or by name:
{ "type": "actions.increment", "args": [5] }
{ "type": "actions.increment", "args": { "by": 5 } }
Limitations to know
The docs note two:
- The action dropdown isn’t supported with the
reduxmiddleware. Dispatch a JSON action like{ "type": "increment" }instead. - With the
storeoption, only the first store’s functions reach the dropdown, because later stores reuse its connection. Their functions can still be called with JSON actions.
According to the docs, the enabled option defaults to true in development mode and false otherwise, so DevTools access stays off in production unless you turn it on. Leave it that way if your state contains anything sensitive.
Combining persist and devtools
Wrap devtools on the outside so persisted state changes show up in DevTools too:
export const useCart = create<CartState>()(
devtools(
persist(cartCreator, { name: 'cart', storage: createJSONStorage(() => idbStorage) }),
{ name: 'CartStore', actionCreators: { add: true, clear: true } },
),
)
Upgrade checklist
- Install
zustand@^5.1.0. - If you wrote workarounds for async
migrateor storage, such as manual awaits or flags, try removing them and rely on the middleware. - Replace polling of
persist.hasHydrated()withawait persist.rehydrate()where you trigger hydration yourself. - Add
actionCreatorsto stores you debug often. - Run your tests. 5.1 is a minor release, but persist timing is exactly what it changes.
Key takeaways
- Zustand 5.1 makes
persistwork properly withPromiseLikestorage reads, migrations and migration writes. persist.rehydrate()now returns aPromise<void>. Await it to avoid hydration flicker and to write reliable tests.- The new
devtoolsactionCreatorsoption lets you dispatch store actions from Redux DevTools, with path wildcards and JSON arguments.
Sources: Zustand v5.1.0 release notes, devtools middleware docs and persist middleware docs.
