Skip to content

Persisting the store's data

Anatole Lucet edited this page Nov 10, 2021 · 11 revisions

Persist middleware

The persist middleware able you to store your Zustand state in a storage (e.g. localStorage, AsyncStorage, IndexedDB, etc...) thus persisting it's data.

Note that this middleware does support both synchronous storages (e.g. localStorage) and asynchronous storages (e.g. AsyncStorage), but using an asynchronous storage does come with a cost. See Hydration and asynchronous storages for more details.

Quick example:

import create from "zustand"
import { persist } from "zustand/middleware"

export const useStore = create(persist(
  (set, get) => ({
    fishes: 0,
    addAFish: () => set({ fishes: get().fishes + 1 })
  }),
  {
    name: "food-storage", // name of item in the storage (must be unique)
    getStorage: () => sessionStorage, // (optional) by default the 'localStorage' is used
  }
))

See the Options section for more details.

Options

name

This is the only required option. The given name is going to be the key used to store your Zustand state in the storage, thus it must be unique.

getStorage

Default: () => localStorage

Ables you to use your own storage. Simply pass a function that returns the storage you want to use.

Example:

export const useStore = create(persist(
  (set, get) => ({
    // ...
  }),
  {
    // ...
    getStorage: () => AsyncStorage,
  }
))

The given storage must match the following interface:

interface Storage {
  getItem: (name: string) => string | null | Promise<string | null>
  setItem: (name: string, value: string) => void | Promise<void>
  removeItem: (name: string) => void | Promise<void>
}

serialize

Schema: (state: Object) => string | Promise<string>

Default: (state) => JSON.stringify(state)

Since the only way to store an object in a storage is via a string, you can use this option to give a custom function to serialize your state to a string.

For example, if you want to store your state in base64:

export const useStore = create(persist(
  (set, get) => ({
    // ...
  }),
  {
    // ...
    serialize: (state) => btoa(JSON.stringify(state)),
  }
))

Note that you would also need a custom deserialize function to make this work properly. See below.

deserialize

Schema: (str: string) => Object | Promise<Object>

Default: (str) => JSON.parse(str)

If you pass a custom serialize function, you will most likely need to pass a custom deserialize function as well.

To continue the example above, you could deserialize the base64 value using the following:

export const useStore = create(persist(
  (set, get) => ({
    // ...
  }),
  {
    // ...
    deserialize: (str) => JSON.parse(atob(str)),
  }
))

partialize

Schema: (state: Object) => Object

Default: (state) => state

Ables you to omit some of the state values to be stored in the storage.

You could omit some fields using the following:

export const useStore = create(persist(
  (set, get) => ({
    foo: 0,
    bar: 1,
  }),
  {
    // ...
    partialize: (state) =>
      Object.fromEntries(
        Object.entries(state).filter(([key]) => !["foo"].includes(key))
      ),
  }
))

And you could allow only spesific fields using the following:

export const useStore = create(persist(
  (set, get) => ({
    foo: 0,
    bar: 1,
  }),
  {
    // ...
    partialize: (state) => ({ foo: state.foo })
  }
))

onRehydrateStorage

Schema: (state: Object) => ((state?: Object, error?: Error) => void) | void

This option ables you to pass a listener function that will be called when the storage is hydrated.

Example:

export const useStore = create(persist(
  (set, get) => ({
    // ...
  }),
  {
    // ...
    onRehydrateStorage: (state) => {
      console.log("hydration starts");
			
      // optional
      return (state, error) => {
        if (error) {
          console.log("an error happened during hydration", error)
        } else {
          console.log("hydration finished")
        }
      }
    }
  }
))

version

Schema: number

Default: 0

If you want to introduce a breaking change in your storage (e.g. rename a field), you can specify a new version number. By default, if the version in the storage does not match the version in the code, the stored value won't be used.

See the migrate option below for more details about handling breaking changes.

migrate

Schema: (persistedState: Object, version: number) => Object | Promise<Object>

Default: (persistedState) => persistedState

You can use this option to handle versions migration. The migrate function takes the persisted state and the version number as arguments. It must return state compliant to the latest version.

For instance, if you want to rename a field, you can use the following:

export const useStore = create(persist(
  (set, get) => ({
    newField: 0, // this field was named otherwise in version 0
  }),
  {
    // ...
    version: 1, // this will trigger a migration if the version in the storage mismatches this one
    migrate: (persistedState, version) => {
      if (version === 0) {
        // if the stored value is in version 0, we rename the field to the new name
        persistedState.newField = persistedState.oldField;
        delete persistedState.oldField;
      }
      
      return persistedState;
    },
  }
))

merge

Schema: (persistedState: Object, currentState: Object) => Object

Default: (persistedState, currentState) => ({ ...currentState, ...persistedState })

In some cases, you might want to use a custom merge function to merge the persisted value with the current state.

By default, the middleware does a shallow merge. The shallow merge might not be enough if you have partially persisted nested objects. For instance, if the storage contains the following:

{
  foo: {
    bar: 0,
  }
}

But your Zustand store contains:

{
  foo: {
    bar: 0,
    baz: 1,
  }
}

The shallow merge will erase the baz field from the foo object. One way to fix this would be to give a custom deep merge function:

export const useStore = create(persist(
  (set, get) => ({
    foo: {
      bar: 0,
      baz: 1,
    },
  }),
  {
    // ...
    merge: (persistedState, currentState) => deepMerge(currentState, persistedState),
  }
))

API

The persist api ables you to do a number of interactions with the persist middleware from inside or outside a React component.

setOptions

Schema: (newOptions: PersistOptions) => void

This method ables you to change the middleware options. Note that the new options will be merged with the current ones.

For instance, this can be used to change the storage name:

useStore.persist.setOptions({
  name: "new-name"
});

Or even to change the storage engine:

useStore.persist.setOptions({
  getStorage: () => sessionStorage,
});

clearStorage

Schema: () => void

This can be used to fully clear the persisted value in the storage.

useStore.persist.clearStorage();

rehydrate

Schema: () => Promise<void>

In some cases, you might want to trigger a rehydration manually. This can be done by calling the rehydrate method.

await useStore.persist.rehydrate();

hasHydrated

Schema: () => boolean

This is a non-reactive getter to know if the storage has been hydrated (note that this does update when calling useStore.persist.rehydrate()).

useStore.persist.hasHydrated();

onHydrate

Schema: (listener: (state) => void) => () => void

The give listener will be called when the hydration process starts.

const unsub = useStore.persist.onHydrate((state) => {
  console.log("hydration starts");
});

// later on...
unsub();

onFinishHydration

Schema: (listener: (state) => void) => () => void

The give listener will be called when the hydration process ends.

const unsub = useStore.persist.onFinishHydration((state) => {
  console.log("hydration finished");
});

// later on...
unsub();

Hydration and asynchronous storages

To explain what's the "cost" of asynchronous storages, you need to understand what's hydration. In a nutshell, hydration is the process of restoring the state from the storage and merging it with the current state.

The persist middleware does two kinds of hydration: synchronous and asynchronous. If the given storage is synchronous (e.g. localStorage), hydration will be done synchronously, and if the given storage is asynchronous (e.g. AsyncStorage), hydration will be done ... 🥁 asynchronously.

But what's the catch? Well, in synchronous hydration, the Zustand store will have been hydrated at its creation. While in asynchronous hydration, the Zustand store will be hydrated later on, in a microtask.

Why does it matter? Asynchronous hydration can cause some unexpected behavior. For instance, if you use Zustand in a React app, the store won't be hydrated at the initial render. In cases where you app depends on the persisted value at page load, you might want to wait until the store is hydrated before showing anything (e.g. your app might think the user is not logged in because it's the default, while in reality the store has not been hydrated yet).

If your app does depends on the persisted value at page load, you can check the How can I check if my store has been hydrated? section in the Q/A.

Q/A

How can I check if my store has been hydrated?

There's a fiew different ways to do this.

You can use the onRehydrateStorage option to update a field in the store:

const useStore = create(
  persist(
    (set, get) => ({
      // ...
      _hasHydrated: false
    }),
    {
      // ...
      onRehydrateStorage: () => () => {
        useStore.setState({ _hasHydrated: true })
      }
    }
  )
);

export default function App() {
  const hasHydrated = useStore(state => state._hasHydrated);

  if (!hasHydrated) {
    return <p>Loading...</p>
  }

  return (
    // ...
  );
}

You can also create a custom useHydration hook:

const useStore = create(persist(...))

const useHydration = () => {
  const [hydrated, setHydrated] = useState(useStore.persist.hasHydrated)
  
  useEffect(() => {
    const unsubHydrate = useStore.persist.onHydrate(() => setHydrated(false)) // Note: this is just in case you want to take into account manual rehydrations. You can remove this if you don't need it/don't want it.
    const unsubFinishHydration = useStore.persist.onFinishHydration(() => setHydrated(true))
    
    setHydrated(useStore.persist.hasHydrated())
    
    return () => {
      unsubHydrate()
      unsubFinishHydration()
    }
  }, [])
  
  return hydrated
}

How to can I use a custom storage engine?

If the storage you want to use does not match the expected API, you can create your own storage:

import create from "zustand"
import { persist, StateStorage } from "zustand/middleware"
import { get, set, del } from 'idb-keyval' // can use anything: IndexedDB, Ionic Storage, etc.

// Custom storage object
const storage: StateStorage = {
  getItem: async (name: string): Promise<string | null> => {
    console.log(name, "has been retrieved");
    return (await get(name)) || null
  },
  setItem: async (name: string, value: string): Promise<void> => {
    console.log(name, "with value", value, "has been saved");
    await set(name, value)
  },
  removeItem: async (name: string): Promise<void> => {
    console.log(name, "has been deleted");
    await del(name)
  }
}

export const useStore = create(persist(
  (set, get) => ({
    fishes: 0,
    addAFish: () => set({ fishes: get().fishes + 1 })
  }),
  {
    name: "food-storage", // unique name
    getStorage: () => storage,
  }
))