Skip to main content

Persistence

There are many ways to persist and restore state "snapshots" in Zedux. It can be done at the atom instance, ecosystem, or plugin level.

you will learn:
  • How to persist and restore a single atom instance's state
  • How to dehydrate and rehydrate an entire ecosystem
  • How to transform non-serializable atom values
  • One of many ways to accomplish this with plugins

Single Atom

Let's start simple. We'll use localStorage for these examples, but you can use whatever means of storage you want.

One way to persist the state of a single atom is to use a separate atom to handle the persist/restore functionality.

Here's an example using a separate localStorageAtom that we give a key param, creating a different localStorageAtom instance for every localStorage key we want to persist data to/from:

Live Sandbox
123456789101112131415161718192021222324252627282930313233343536
const localStorageAtom = atom('localStorage', (key: string) => {
const val = localStorage.getItem(key)

// we're using the function overload of `injectStore` to prevent JSON.parse
// from running unnecesarily on reevaluations:
const store = injectStore(() =>
createStore(null, val ? JSON.parse(val) : undefined)
)

const update = (newVal: any) => {
store.setState(newVal)
localStorage.setItem(key, JSON.stringify(newVal))
}

return api(store).setExports({ update })
})

// this atom's state now persists across page reloads:
const usernameAtom = atom('username', () => {
const [storedName, { update }] = injectAtomState(localStorageAtom, [
'username', // <- the localStorage key
])

return api(storedName || '').setExports({ update })
})

function Username() {
const [state, { update }] = useAtomState(usernameAtom)

return (
<div>
<input onChange={event => update(event.target.value)} value={state} />
<button onClick={() => sandbox.reload()}>Refresh sandbox</button>
</div>
)
}

Another way is to use an injector:

Live Sandbox
12345678910111213141516171819202122232425262728293031323334
const injectLocalStorage = (key: string, defaultVal: any) => {
const val = localStorage.getItem(key)

// we're using the function overload of `injectStore` to prevent JSON.parse
// from running unnecesarily on reevaluations:
const store = injectStore(() =>
createStore(null, val ? JSON.parse(val) : defaultVal)
)

const update = (newVal: any) => {
store.setState(newVal)
localStorage.setItem(key, JSON.stringify(newVal))
}

return [store, update] as const
}

// this atom's state now persists across page reloads:
const usernameAtom = atom('username', () => {
const [store, update] = injectLocalStorage('username', '')

return api(store).setExports({ update })
})

function Username() {
const [state, { update }] = useAtomState(usernameAtom)

return (
<div>
<input onChange={event => update(event.target.value)} value={state} />
<button onClick={() => sandbox.reload()}>Refresh sandbox</button>
</div>
)
}

Yep, these approaches are very similar.

Ecosystem Persistence

There are a few ways to get a state snapshot of an entire ecosystem. For example, you could call ecosystem.findAll() and map each instance to a value yourself. But ecosystems have a method that's specifically designed for persistence:

ecosystem.dehydrate()

You can get a snapshot of the state of all atoms in an ecosystem with ecosystem.dehydrate({ transform: false })

import { atom, createEcosystem } from '@zedux/react'

const exampleAtom = atom('example', () => 'Hello, world!')
const ecosystem = createEcosystem({ id: 'root' })
ecosystem.getInstance(exampleAtom)

const snapshot = ecosystem.dehydrate({ transform: false })

This may be all you need. However, passing { transform: false } makes ecosystem.dehydrate() very "dumb" - it doesn't do anything special to transform the state, it just returns it as-is. Since atoms can hold anything, you may have a situation where you're storing non-serializable values (namely functions) in atoms. Data types from Immutable.js, for example, require an extra step to serialize/deserialize values.

For these atoms, you have two options:

  • Skip the non-serializable atom.
  • Transform the atom's value to something that can be serialized.

First things first:

Skipping Atoms

You can blacklist atoms you don't want to dehydrate by passing exclude and/or excludeFlags options:

Live Sandbox
12345678910111213
const skippedAtom = atom('skipped', ':(', { flags: ['no-persist'] })
const alsoSkippedAtom = atom('alsoSkipped', ':O')
const persistedAtom = atom('persisted', ':)')

const ecosystem = createEcosystem({ id: 'exludes-example' })
ecosystem.getInstance(skippedAtom)
ecosystem.getInstance(alsoSkippedAtom)
ecosystem.getInstance(persistedAtom)

const snapshot = ecosystem.dehydrate({
exclude: [alsoSkippedAtom],
excludeFlags: ['no-persist'],
})

Similarly, you can specify a whitelist of atoms you want to be dehydrated by passing include and/or includeFlags options:

Live Sandbox
12345678910111213
const skippedAtom = atom('skipped', ':(')
const persistedAtom = atom('persisted', ':)', { flags: ['persist'] })
const alsoPersistedAtom = atom('alsoPersisted', ':D')

const ecosystem = createEcosystem({ id: 'includes-example' })
ecosystem.getInstance(skippedAtom)
ecosystem.getInstance(persistedAtom)
ecosystem.getInstance(alsoPersistedAtom)

const snapshot = ecosystem.dehydrate({
include: [alsoPersistedAtom],
includeFlags: ['persist'],
})

Excludes take precedence over includes if you pass both.

Now for transforming values:

dehydrate Atom Config

Atoms can be configured with a dehydrate config option:

const exampleAtom = atom('example', () => initialState, {
dehydrate: state => transform(state),
})

To enable value transforming, simply omit { transform: false } in the ecosystem.dehydrate() call. Transforms are enabled by default.

ecosystem.dehydrate() returns an object mapping atom instance ids to their current value. But with transforms enabled, .dehydrate() does a little more magic.

ecosystem.dehydrate() calls the dehydrate atom config option (when specified) to transform the state of individual atom instances.

Here's an example of an atom whose value is a JS Map, which can't be directly stringified. We use the dehydrate atom config option to tell Zedux how to transform this atom instance's state to a serializable value for dehydration:

Live Sandbox
123456789101112131415161718192021222324252627282930313233343536373839
const transformingAtom = atom(
'transforming',
() => {
const store = injectStore(new Map([['counter', 0]]))

injectEffect(() => {
const handle = setInterval(() => {
store.setState(state =>
new Map(state).set('counter', state.get('counter') + 1)
)
}, 1000)

return () => clearInterval(handle)
})

return store
},
// turn the map into a JS object for serialization:
{ dehydrate: state => Object.fromEntries(state.entries()) }
)

function Dehydrator() {
useAtomValue(transformingAtom) // just to instantiate it for this example
const ecosystem = useEcosystem()
const [dehydration, setDehydration] = useState()

return (
<>
<pre>{dehydration}</pre>
<button
onClick={() =>
setDehydration(JSON.stringify(ecosystem.dehydrate(), null, 2))
}
>
Dehydrate!
</button>
</>
)
}

Now calls to ecosystem.dehydrate() call the dehydrate atom config function to transform the state of all instances of the transforming atom.

ecosystem.hydrate()

The inverse of ecosystem.dehydrate(). After you get a state snapshot, use ecosystem.hydrate() to restore it.

Live Sandbox
12345678910111213141516
const fooAtom = atom('foo', 1)
const barAtom = atom('bar', 2)

const ecosystem = createEcosystem({ id: 'hydrate-example' })
const fooInstance = ecosystem.getInstance(fooAtom)
const barInstance = ecosystem.getInstance(barAtom)

const snapshot1 = ecosystem.dehydrate()

fooInstance.setState(3) // change foo's state from 1 to 3
const snapshot2 = ecosystem.dehydrate()

ecosystem.hydrate(snapshot1) // restore the initial snapshot
const snapshot3 = ecosystem.dehydrate()

const output = { snapshot1, snapshot2, snapshot3 }

After hydration, any newly-initialized atom instances that have a matching key in the hydrated snapshot will receive their hydrated state after initializing.

Live Sandbox
123456789101112
const hydrateMe = atom('hydrateMe', (id: number) => {
return injectStore('non-hydrated state')
})

const ecosystem = createEcosystem({ id: 'atom-hydrate-example' })
ecosystem.hydrate({ 'hydrateMe-[0]': 'hydrated state' })

// these instances are created after hydration:
ecosystem.getInstance(hydrateMe, [0])
ecosystem.getInstance(hydrateMe, [1])

const snapshot = ecosystem.dehydrate()

Hydration will also immediately update any existing atom instances with ids in the snapshot. Pass { retroactive: false } as the second parameter to ecosystem.hydrate() to disable this. With this option, only instances that are created after hydration will have their state hydrated.

Live Sandbox
12345678910111213
const hydrateMe = atom('hydrateMe', (id: number) => {
return injectStore('non-hydrated state')
})

const ecosystem = createEcosystem({ id: 'no-retroactive-example' })

// these instances are created before hydration, so won't be updated:
ecosystem.getInstance(hydrateMe, [0])
ecosystem.getInstance(hydrateMe, [1])

ecosystem.hydrate({ 'hydrateMe-[0]': 'hydrated state' }, { retroactive: false })

const snapshot = ecosystem.dehydrate()

hydrate Atom Config

Atoms can be configured with a hydrate transformation function:

const exampleAtom = atom('example', initialState, {
hydrate: rawVal => transform(rawVal),
})

This can be used to transform dehydrated values back to non-serializable form. Let's plug that into the JS Map example:

Live Sandbox
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
const transformingAtom = atom(
'transforming',
() => {
const store = injectStore(new Map([['counter', 0]]))

injectEffect(() => {
const handle = setInterval(() => {
store.setState(state =>
new Map(state).set('counter', state.get('counter') + 1)
)
}, 1000)

return () => clearInterval(handle)
})

return store
},
// transform the map to and from a JS object:
{
dehydrate: state => Object.fromEntries(state.entries()),
hydrate: rawState => new Map(Object.entries(rawState)),
}
)

function Counter() {
const map = useAtomValue(transformingAtom)

return <div>Counter: {map.get('counter')}</div>
}

function Dehydrator() {
const ecosystem = useEcosystem()
const [dehydration, setDehydration] = useState()

return (
<>
<Counter />
<pre>{dehydration}</pre>
<button
onClick={() =>
setDehydration(JSON.stringify(ecosystem.dehydrate(), null, 2))
}
>
Dehydrate!
</button>
{dehydration && (
<button onClick={() => ecosystem.hydrate(JSON.parse(dehydration))}>
Hydrate!
</button>
)}
</>
)
}

Store Hydration

The default hydration flow hydrates the store after the atom instance's initial evaluation ends. This means that if your state factory's main store was injected without subscribe: false, it will immediately trigger a second evaluation.

This is usually fine. But you can change this behavior in 2 steps:

  1. Hydrate the injected store's state yourself.
  2. Tell Zedux not to automatically hydrate this atom.

To hydrate the store's state, use the hydrate option of injectStore:

const store = injectStore(defaultValue, { hydrate: true })

The store's state will be set to the hydration matching the current atom instance, after passing it to the hydrate atom config option (if any) to transform the value. If there is no hydration for the current atom instance, passing { hydrate: true } does nothing.

Live Sandbox
123456789101112
const myAtom = atom(
'myKey',
() => {
return injectStore('default', { hydrate: true })
},
{ hydrate: hydration => `${hydration} and transformed!` }
)

const ecosystem = createEcosystem({ id: 'hydrate-flow-example' })
ecosystem.hydrate({ myKey: 'hydrated' })

const output = ecosystem.get(myAtom)

When injectStore()'s function overload is used, the hydration will be passed to your storeFactory function. It's up to you to use the hydration to set your store's initial state.

const store = injectStore(
hydration => createStore(null, hydration ?? defaultValue),
{ hydrate: true }
)
// (this specific example is exactly equivalent to:)
const store = injectStore(defaultValue, { hydrate: true })

If there is no hydration, this parameter will be undefined.

Now for the 2nd step:

manualHydration

Set this atom config option to true to prevent Zedux from automatically hydrating instances of the atom. You'll usually want to pair this with passing hydrate: true to a single injectStore in the atom's state factory.

const exampleAtom = atom(
'example',
() => {
const store = injectStore(defaultValue, { hydrate: true })
const internalStore = injectStore() // can inject other, non-hydrated stores

return store // Zedux won't hydrate this store's state after init now
},
{
manualHydration: true, // prevent automatic hydration
}
)

Hydration consumption

When Zedux hydrates an atom instance, it deletes the hydration from the ecosystem. This makes it easy to see which hydrations have been "consumed" by simply accessing the ecosystem's .hydration property:

Live Sandbox
123456789101112
const fooAtom = atom('foo', 1)

const ecosystem = createEcosystem({ id: 'hydration-consumption-example' })
ecosystem.hydrate({ foo: 2, bar: 3 })

// "consume" the hydration for `foo`:
const fooInstance = ecosystem.getInstance(fooAtom)

const output = {
fooValue: fooInstance.getState(),
unusedHydrations: ecosystem.hydration,
}
note

Hydrations will also be consumed for retroactively-hydrated atom instances.

This also means that if an atom instance is destroyed and recreated, the new atom instance will not be hydrated again. This is usually the desired behavior, but if you do need this you can accomplish it by "rehydrating" the ecosystem before the instance is destroyed:

const rehydratingAtom = atom(
'rehydrating',
(...params: any[]) => {
const { ecosystem } = injectAtomGetters()
const store = injectStore('default state', { hydrate: true })
const hydrationRef = injectRef(store.getState()) // capture the hydrated state

injectEffect(() => {
// this effect does nothing but setup cleanup
return () => {
// one way to get the current atom instance's id:
const instance = ecosystem.getInstance(rehydratingAtom, [...params])
// rehydrate!
ecosystem.hydrate({ [instance.id]: hydrationRef.current })
}
}, []) // [] - cleanup will only run on instance destruction

return store
},
{ ttl: 0 }
)

Reactivity Via Plugins

The localStorage single atom examples at the beginning of this page were beautifully reactive - they would persist the atom's state immediately on state change. None of the ecosystem examples so far do that. You have to manually call ecosystem.dehydrate() e.g. in response to a button press or on an interval.

By default, ecosystems don't receive any kind of update when individual atom instances change state. This is by design, for performance reasons. However, it is possible to turn this behavior on with plugins.

Just turn on the stateChanged mod.

import { createEcosystem, ZeduxPlugin } from '@zedux/react'

const plugin = new ZeduxPlugin({
initialMods: ['stateChanged'],

registerEcosystem: ecosystem => {
const subscription = ecosystem.modsMessageBus.subscribe({
effects: ({ action }) => {
if (action.type !== ZeduxPlugin.actions.stateChanged.type) return

const snapshot = ecosystem.dehydrate({ includeFlags: ['persist'] })
localStorage.setItem('snapshot', snapshot)
},
})

return () => subscription.unsubscribe()
},
})

const ecosystem = createEcosystem({ id: 'root' })
ecosystem.registerPlugin(plugin)

Recap

  • Use ecosystem.dehydrate() to get a state snapshot of all or some of the atoms in the ecosystem.
    • Use the dehydrate atom config option to transform the state of individual atoms to a serializable or shortened form.
  • Use ecosystem.hydrate() to restore a state snapshot.
    • Use the hydrate atom config option to transform hydrations for individual atoms.
  • The { hydrate: true } injectStore option sets the store's initial state to the hydrated value.
  • Use the manualHydration atom config option to prevent Zedux from automatically hydrating individual atoms.
  • Use a plugin with the stateChanged mod to reactively persist snapshots.

Next Steps

With the persistence tools mastered, you can use these tools to implement SSR.