Zustand keeps small global stores behind a hook, and its persist middleware writes a store to storage so the values survive a restart. In an Expo app the open questions are which storage backs the store and whether it runs in Expo Go. For most apps the answer is the default: Zustand on expo-sqlite/kv-store. Zustand has no runtime dependencies, so Expo Go runs it, and the same storage adapter serves the built-in stores.
Add the bridge with one command
bun run setup --state zustand --with-examplesWithout the flag you get the bridge and its test. With it, you also get src/stores/example-store.ts, the /examples/store route and its screen. The zustand module needs a storage module, and kv-store is the default. Pick mmkv or async-storage only with a reason, and see AsyncStorage vs MMKV vs expo-sqlite/kv-store for the trade-offs.
A persisted store, step by step
A reminder time and an on or off switch make a good first store: a few values, read on launch. Put this in src/stores/reminder-store.ts:
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { zustandStorage } from "@/stores/zustand-storage";
type ReminderState = {
hour: number;
enabled: boolean;
setHour: (hour: number) => void;
toggle: () => void;
};
export const useReminderStore = create<ReminderState>()(
persist(
(set) => ({
hour: 9,
enabled: false,
setHour: (hour) => set({ hour }),
toggle: () => set((s) => ({ enabled: !s.enabled })),
}),
{
name: "readynative:reminders",
storage: createJSONStorage(() => zustandStorage),
partialize: (s) => ({ hour: s.hour, enabled: s.enabled }),
}
)
);Three details carry the store. The name is the storage key, so it must be unique, and the template prefixes its own keys with readynative:. createJSONStorage turns the state into a JSON string, because the storage holds strings. partialize decides what is saved: here the two values, not the actions. The actions come back from the creator on every launch. The persist reference lists the other options.
Read it in components
Select the value a component renders, so it re-renders only when that value changes:
const hour = useReminderStore((s) => s.hour);Outside React, such as in a notification handler, call the store directly with useReminderStore.getState().toggle().
Where a change is saved
Every write follows the path in the diagram. The store is the top box, and the backend you chose with setup is the bottom one.
persist serializes the state with createJSONStorage. zustandStorage calls the sync method on native and the async method on web. The storage adapter hands the string to the backend, and that backend is the one setup chose. Why the default works in Expo Go, and why MMKV needs a development build, is covered in AsyncStorage vs MMKV vs expo-sqlite/kv-store.
On native the saved value is read back synchronously. The store is hydrated when it is created, so the first render already has the saved reminder time. On web the adapter is async, and Zustand hydrates in a later microtask, so the first render shows the initial state. The adapter uses only the async calls on web, because Expo's SQLite on web needs Metro set up for wasm files and the COOP and COEP headers. If a wrong first frame would be visible, hold the first paint until useReminderStore.persist.onFinishHydration tells you hydration has finished.
Change the shape without losing data
Saved data outlives your code. The persisted value carries a version, which is 0 by default. When you change the shape, raise version and add migrate. Without migrate, a value saved under another version is not used, so the user starts from the initial state. With migrate, the old value is converted first. Here a version 0 store called the field reminderHour:
{
name: "readynative:reminders",
version: 1,
migrate: (persisted, version) => {
if (version === 0) {
const old = persisted as { reminderHour: number; enabled: boolean };
return { hour: old.reminderHour, enabled: old.enabled };
}
return persisted as ReminderState;
},
storage: createJSONStorage(() => zustandStorage),
partialize: (s) => ({ hour: s.hour, enabled: s.enabled }),
}Adding a field needs no migration. Zustand merges the saved value into the current state with a shallow merge, so a field the saved value does not have keeps its initial value. Use migrate when a field is renamed or changes type, because the migration sees the stored state exactly as it was written, with the version it was written under.
Test the migration with a value saved by the previous build before you ship. A store that loads fine in a fresh install proves nothing about the users who already have data.
Test the store
The example store ships with a Jest test. It resets the store before each case, checks that the counter moves, and reads the saved JSON back through the storage adapter. Copy that pattern for your store, then run the suite:
bun run testFor the Jest setup behind it, read jest-expo setup, Expo Router tests and fixes. To see what is saved, read the key through the adapter. The value is JSON with the state and the version:
import { storage } from "@/lib/storage";
const raw = await storage.getItem("readynative:reminders");
JSON.parse(raw!); // { state: { hour: 9, enabled: false }, version: 0 }Keep some data out of the store
Tokens and passwords do not belong in a persisted store, whatever the backend. Keep them in expo-secure-store, which the key/value guide covers. Server data is a poor fit too. A list from your API is better in TanStack Query, which can persist its own cache. See TanStack Query in Expo: offline and refetch.
To delete what is saved, for example when a user deletes their account, call useReminderStore.persist.clearStorage(). It removes everything stored under the store's name key. Reset the values in memory with an action of your own.
Zustand or something else
Pick Zustand when you think in stores and selectors. Pick Jotai if you think in atoms. If one screen owns its state, skip the store: a store earns its place once two screens need the same data. Theme mode and onboarding do not move into it. They stay on the built-in persisted store in core.
To take Zustand out again, run the setup with the none option. It removes the files and the dependency:
bun run setup --state noneRead next: Expo Go vs development build, for why a native backend needs a build, then the Zustand module docs.
FAQ
Does zustand work in Expo Go?
Yes. Zustand has no runtime dependencies, and the default storage, expo-sqlite/kv-store, is included in Expo Go, so a persisted store works there too. A native backend such as MMKV needs a development build.
How do I persist zustand state in Expo?
Wrap the store in persist with a unique name and pass storage: createJSONStorage(() => zustandStorage). The zustand setup writes that bridge on top of the storage adapter, which is expo-sqlite/kv-store by default.
Does zustand persist work on web in Expo?
Yes, with one difference: web storage is async, so the first render shows the initial state until hydration finishes. If a wrong first frame would show, wait for persist.onFinishHydration before you paint.
Why does my persisted zustand state reset after an update?
Usually the saved version no longer matches the code. A stored value whose version differs is not used, and the default version is 0. Raise version and add a migrate function that converts the old shape.
More guides
- Unistyles 3 with Expo: a working setup
- Expo environment variables: which are public?
- TanStack Query in Expo: offline and refetch
- jest-expo setup, Expo Router tests and fixes
- Expo auth: Clerk vs Supabase vs Better Auth
- AsyncStorage vs MMKV vs expo-sqlite/kv-store
- Expo i18n: i18next or Lingui, compared
- Expo android widgets: expo-widgets on SDK 58
- Tamagui in Expo: setup, styling and themes
- Best paywall tools for Expo in 2026
- Expo widgets on iOS: a working setup
- RevenueCat subscriptions in an Expo Router app
- Supabase auth in Expo: email, Apple and Google
- Expo push notifications: setup, token, testing
- Expo Go vs development build: limitations
- The best Expo boilerplates, compared