Making Zustand Persist Play Nice with Async Storage & React Suspense, Part 1/2
If you find yourself needing to use async storage for your app, and you are using Zustand for your global store, you may already know that Zustand docs warn of an extra 'cost' for that storage type. What you might not know, but will soon, is what that cost is or how to properly set up your store code to deal with it. At the same time, I am going to show you how to make your store trigger React Suspense with useSyncExternalStorage and make absolutely certain that your store never gets accidentally overwritten by the persist middleware, often at redeploy, but potentially on refresh or opening a new tab. Please note that not all browsers behave the same when it comes to this overwrite issue so make sure you are testing your app appropriately. This article is not a dive into how to use Zustand. If you need that, I have a blog here that explains the store setup with partial persist to local storage. This article assumes knowledge of basic Zustand, React, Suspense, and what it means to access something asynchronously. The application letting us borrow it's code today is an app that allows users to discover, save and watch movies and tv shows. The store persists three things: an object that holds bookmarked item information, an array that holds the user's last 20 searches, and an array that holds basic information about what the user is currently watching. The async storage is IndexedDB which means we will need a custom store, so I am using the package idb-keyval to easily set up the storage with Zustand. If you don't want to add that package and just want basic code for a custom store, see this gist, and note that you will have to finish the code that is started for you there. This code is written in TypeScript but it is quite easy to disregard that if it doesn't fit your needs. The store code looks like this to start: import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface BingeBoxStore { bookmarks: { [key: string]: { id: string; type: string; dateAdded: number } }; previousSearches: string[]; continueWatching: { id: number; media_type: string; lastUpdated: number; title: string; season?: number; episode?: number; poster_path: string; release_date?: string; runtime?: string; }[]; } export const useStore = create()( persist( (set, get) => ({ // state bookmarks: {}, previousSearches: [], continueWatching: [], // functions for adding, deleting, clearing etc... would go here. }), { name: process.env.NODE_ENV === 'production' ? 'idb-storage' : 'idb-storage-dev', storage: idbStorage, version: 0, partialize: (state) => ({ bookmarks: state.bookmarks, previousSearches: state.previousSearches, continueWatching: state.continueWatching, }), } ) ); This is just basic store code with the store interface, state, methods to update state omitted for brevity, and the storage option pointing to idbStorage that is currently missing. That's the next step - to set up that storage. import { get, set, del } from 'idb-keyval'; export const idbStorage = { getItem: async (name: string) => { const value = await get(name); return value ?? null; }, setItem: async (name: string, value: any) => { await set(name, value); }, removeItem: async (name: string) => { await del(name); }, }; You can see this is very simple code that gets, sets, and removes items from the storage in an asynchronous manner. At this point the code could technically work. If you only wanted to learn how to set up Zustand with IndexedDB you pretty much have it. However, if you stop here you might want to test very carefully that your saved store data is pulled back into Zustand properly after redeploy, refresh, tab changes and be sure to test across all browsers. Because what could happen here is that Zustand's persist middleware is going to rehydrate data from the store back into memory under all of those conditions, immediately, but it will also want to set up a store as part of it's innate code - nothing is going to tell it to wait to asynchronously access IDB, so it might accidently set up a NEW empty store and persist that back to the DB even as you are pulling in the data from the DB. This creates a race condition where the middleware might win and persist back to IndexedDB a fresh, empty store. You can read more here about hydration and other things that happen from async storage. I wouldn't even risk this if I was you, even if you don't notice it, because the size of IndexedDB or any other hiccups that could slow down that async access might one day pop up for your user, and users do not like it when their persisted data is lost. Even just having to log in again when they don't think they should have to is enough to irritate users, but in my app, all of the bookmarks, continue watching, etc being

If you find yourself needing to use async storage for your app, and you are using Zustand for your global store, you may already know that Zustand docs warn of an extra 'cost' for that storage type. What you might not know, but will soon, is what that cost is or how to properly set up your store code to deal with it. At the same time, I am going to show you how to make your store trigger React Suspense with useSyncExternalStorage and make absolutely certain that your store never gets accidentally overwritten by the persist middleware, often at redeploy, but potentially on refresh or opening a new tab. Please note that not all browsers behave the same when it comes to this overwrite issue so make sure you are testing your app appropriately.
This article is not a dive into how to use Zustand. If you need that, I have a blog here that explains the store setup with partial persist to local storage. This article assumes knowledge of basic Zustand, React, Suspense, and what it means to access something asynchronously.
The application letting us borrow it's code today is an app that allows users to discover, save and watch movies and tv shows. The store persists three things: an object that holds bookmarked item information, an array that holds the user's last 20 searches, and an array that holds basic information about what the user is currently watching. The async storage is IndexedDB which means we will need a custom store, so I am using the package idb-keyval to easily set up the storage with Zustand. If you don't want to add that package and just want basic code for a custom store, see this gist, and note that you will have to finish the code that is started for you there. This code is written in TypeScript but it is quite easy to disregard that if it doesn't fit your needs.
The store code looks like this to start:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface BingeBoxStore {
bookmarks: { [key: string]: { id: string; type: string; dateAdded: number } };
previousSearches: string[];
continueWatching: {
id: number;
media_type: string;
lastUpdated: number;
title: string;
season?: number;
episode?: number;
poster_path: string;
release_date?: string;
runtime?: string;
}[];
}
export const useStore = create()(
persist(
(set, get) => ({
// state
bookmarks: {},
previousSearches: [],
continueWatching: [],
// functions for adding, deleting, clearing etc... would go here.
}),
{
name:
process.env.NODE_ENV === 'production'
? 'idb-storage'
: 'idb-storage-dev',
storage: idbStorage,
version: 0,
partialize: (state) => ({
bookmarks: state.bookmarks,
previousSearches: state.previousSearches,
continueWatching: state.continueWatching,
}),
}
)
);
This is just basic store code with the store interface, state, methods to update state omitted for brevity, and the storage option pointing to idbStorage that is currently missing. That's the next step - to set up that storage.
import { get, set, del } from 'idb-keyval';
export const idbStorage = {
getItem: async (name: string) => {
const value = await get(name);
return value ?? null;
},
setItem: async (name: string, value: any) => {
await set(name, value);
},
removeItem: async (name: string) => {
await del(name);
},
};
You can see this is very simple code that gets, sets, and removes items from the storage in an asynchronous manner.
At this point the code could technically work. If you only wanted to learn how to set up Zustand with IndexedDB you pretty much have it. However, if you stop here you might want to test very carefully that your saved store data is pulled back into Zustand properly after redeploy, refresh, tab changes and be sure to test across all browsers. Because what could happen here is that Zustand's persist middleware is going to rehydrate data from the store back into memory under all of those conditions, immediately, but it will also want to set up a store as part of it's innate code - nothing is going to tell it to wait to asynchronously access IDB, so it might accidently set up a NEW empty store and persist that back to the DB even as you are pulling in the data from the DB. This creates a race condition where the middleware might win and persist back to IndexedDB a fresh, empty store. You can read more here about hydration and other things that happen from async storage.
I wouldn't even risk this if I was you, even if you don't notice it, because the size of IndexedDB or any other hiccups that could slow down that async access might one day pop up for your user, and users do not like it when their persisted data is lost. Even just having to log in again when they don't think they should have to is enough to irritate users, but in my app, all of the bookmarks, continue watching, etc being lost would be very annoying. So here is some code that will make Zustand merge the new store with the old store. That's all it takes:
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial;
// If the persisted state has data (not empty objects),
// prefer it over the initial empty state
return {
...currentState,
bookmarks:
Object.keys(persisted.bookmarks || {}).length > 0
? persisted.bookmarks!
: currentState.bookmarks,
previousSearches:
(persisted.previousSearches || []).length > 0
? persisted.previousSearches!
: currentState.previousSearches,
continueWatching:
(persisted.continueWatching || []).length > 0
? persisted.continueWatching!
: currentState.continueWatching,
};
},
Obviously your code is going to be different, but the built in merge code is what I want to show you (please read about merge to know if you need deepMerge, as this app doesn't use it). This goes in the second set of curly braces, under the name or version, above the partialize... anywhere in that area. Don't try to put it with your methods to change state.
At this point, you may or may not see a need for merge, but I suggest you do it if you want to go on with Suspense integration (part 2). I am going to add complexity with useSyncExternalStore (uSES) a built in hook from the React library designed to make it easy to integrate your store with React, and I will show you how to make different hooks to access your store based on wanting to throw a promise to Suspense or just have the store load without Suspense. There will be additions to state such as isLoading, loading, and a custom intializeStore function that will actually wait to return data until Zustand notifies that hydration is complete. Having the merge function just guarantees the user's data stays intact with so much going on!
Thanks for reading part I, part II is on the way! If you are ready to increase complexity and make your app even more awesome with Suspense, stay tuned. The link for part 2 will be posted here shortly.