Small module for Effector ☄️ to sync stores with different storage backends (local storage, session storage, async storage, IndexedDB, cookies, server-side storage, etc).
- Install
- Usage
- Usage with domains
- Formulae
createPersistfactory- Advanced usage
- Storage adapters
- FAQ
- TODO
- Sponsored
Depending on your package manager
# using `pnpm` ↓
$ pnpm add effector-storage
# using `yarn` ↓
$ yarn add effector-storage
# using `npm` ↓
$ npm install --save effector-storageDocs: effector-storage/local
import { persist } from 'effector-storage/local'
// persist store `$counter` in `localStorage` with key 'counter'
persist({ store: $counter, key: 'counter' })
// if your storage has a name, you can omit `key` field
persist({ store: $counter })Stores, persisted in localStorage, are automatically synced between two (or more) windows/tabs. Also, they are synced between instances, so if you will persist two stores with the same key — each store will receive updates from another one.
ℹ️ If you need just basic bare-minimum functionality, you can take a look at effector-localstorage library. It has a similar API, and it is much simpler and smaller.
Docs: effector-storage/session
Same as above, just import persist from 'effector-storage/session':
import { persist } from 'effector-storage/session'Stores, persisted in sessionStorage, are synced between instances, but not between different windows/tabs.
Docs: effector-storage/query
You can reflect a plain string store value in a query string parameter using this adapter. Think of it as synchronizing a store value with a query string parameter.
import { persist } from 'effector-storage/query'
// persist store `$id` in query string parameter 'id'
persist({ store: $id, key: 'id' })If two (or more) stores are persisted in query string with the same key — they are synced between themselves.
Docs: effector-storage/broadcast
You can sync stores across different browsing contexts (tabs, windows, workers), just import persist from 'effector-storage/broadcast':
import { persist } from 'effector-storage/broadcast'You can find a collection of useful adapters in effector-storage-extras. That side repository was created to avoid bloating effector-storage with dependencies and adapters that depend on other libraries.
You can use persist inside Domain's onCreateStore hook:
import { createDomain } from 'effector'
import { persist } from 'effector-storage/local'
const app = createDomain('app')
// this hook will persist every store, created in domain,
// in `localStorage`, using stores' names as keys
app.onCreateStore((store) => persist({ store }))
const $store = app.createStore(0, { name: 'store' })import { persist } from 'effector-storage/<adapter>'persist({ store, ...options }): Subscriptionpersist({ source, target, ...options }): Subscription
In order to synchronize something, you need to specify Effector units. Depending on requirements, you may want to use the store parameter, or source and target parameters:
store(Store): Store to synchronize with local/session storage.source(Event | Effect | Store): Source unit, which updates will be sent to local/session storage.target(Event | Effect | Store): Target unit, which will receive updates from local/session storage (as well as initial value). Must be different thansourceto avoid circular updates —sourceupdates are passed directly totarget.
Note
These are common options, valid for any adapter. However, individual storage adapters may define and use their own specific options. Please reference the documentation for your exact adapter for more information.
key? (string): Key for local/session storage, to store value in. If omitted —storename is used. Note! Ifkeyis not specified,storemust have aname! You can use'effector/babel-plugin'to have those names automatically.keyPrefix? (string): Prefix, used in adapter, to be concatenated tokey. By default =''.clock? (Event | Effect | Store): Unit, if passed – then value fromstore/sourcewill be stored in the storage only upon its trigger.pickup? (Event | Effect | Store, or array of these units): Unit (or array of units), which you can specify to updatestorevalue from storage. This unit can also set a special context for adapter. Note! When you addpickup,persistwill not get initial value from storage automatically!context? (Event | Effect | Store): Unit, which can set a special context for adapter.contract? (Contract): Rule to statically validate data from storage.done? (Event | Effect | Store): Unit, which will be triggered on each successful read or write to/from storage.
Payload structure:fail? (Event | Effect | Store): Unit, which will be triggered in case of any error (serialization/deserialization error, storage is full and so on). Note! Iffailunit is not specified, any errors will be printed usingconsole.error(Error).
Payload structure:key(string): Samekeyas above.keyPrefix(string): Prefix, used in adapter, to be concatenated tokey. By default =''.operation('set'|'get'|'validate'): Type of operation, read (get), write (set) or validation against contract (validate).error(Error): Error instancevalue? (any): In case of 'set' operation — value fromstore. In case of 'get' operation could contain raw value from storage or could be empty.
finally? (Event | Effect | Store): Unit, which will be triggered either in case of success or error.
Payload structure:key(string): Samekeyas above.keyPrefix(string): Prefix, used in adapter, to be concatenated tokey. By default =''.operation('set'|'get'|'validate'): Type of operation, read (get), write (set) or validation against contract (validate).status('done'|'fail'): Operation status.error? (Error): Error instance, in case of error.value? (any): Value, if it exists (see above).
- (Subscription): You can use this subscription to remove store association with storage, if you don't need them to be synced anymore. It is a function.
You can use contract option to validate data from storage. Contract has the following type definition:
export type Contract<Data> =
| ((raw: unknown) => raw is Data)
| StandardSchemaV1<unknown, Data>
| {
isData: (raw: unknown) => raw is Data
getErrorMessages: (raw: unknown) => string[]
}So, it could be simple type guard function in trivial use cases, a Standard Schema compatible validator, or more complex object with isData type guard and getErrorMessages function, which returns array of error messages. This format is fully compatible with Farfetched contracts, so you can use any adapter from Farfetched (runtypes, zod, io-ts, superstruct, typed-contracts, valibot) with persist and contract option:
// simple type guard
persist({
store: $counter,
key: 'counter',
contract: (raw): raw is number => typeof raw === 'number',
})// complex contract with Farfetched adapter
import * as s from 'superstruct'
import { superstructContract } from '@farfetched/superstruct'
const Asteroid = s.type({
type: s.literal('asteroid'),
mass: s.number(),
})
persist({
store: $asteroid,
key: 'asteroid',
contract: superstructContract(Asteroid),
})There are two gotchas with contracts:
- From
effector-storagepoint of view, it is absolutely normal when there is no persisted value in storage yet. So,undefinedis always valid, even if a contract does not explicitly allow it. effector-storagedoes not prevent persisting invalid data to the storage, but it will validate it nonetheless, after persisting, so, if you write invalid data to the storage,failwill be triggered, but data will be persisted.
Without specifying the pickup property, calling persist will immediately call the adapter to get the initial value. In case of synchronous storage (like localStorage or sessionStorage), this action will synchronously set store value and call done/fail/finally right away. You should take that into account if you add logic based on done; for example, place persist after that logic (see issue #38 for more details).
You can modify adapter to be asynchronous to mitigate this behavior with async function.
In rare cases you might want to use createPersist factory. It allows you to specify some adapter options, like keyPrefix.
import { createPersist } from 'effector-storage/local'
const persist = createPersist({
keyPrefix: 'app/',
})
// ---8<---
persist({
store: $store1,
key: 'store1', // localStorage key will be `app/store1`
})
persist({
store: $store2,
key: 'store2', // localStorage key will be `app/store2`
})pickup? (Event | Effect | Store, or array of these units): Unit (or array of units), which you can specify to updatestorevalue from storage. This unit can also set a special context for adapter. Note! When you addpickup,persistwill not get initial value from storage automatically!context? (Event | Effect | Store): Unit, which can set a special context for adapter.keyPrefix? (string): Key prefix for adapter. It will be concatenated with anykey, given to returnedpersistfunction.contract? (Contract): Rule to statically validate data from storage.
- Custom
persistfunction, with predefined adapter options.
effector-storage consists of a core module and adapter modules.
The core module itself does nothing with actual storage; it just connects Effector units to the storage adapter using a couple of Effects and a bunch of connections.
The storage adapter gets and sets values, and also can asynchronously emit values on storage updates.
import { persist } from 'effector-storage'Core function persist accepts all common options, as persist functions from sub-modules, plus additional one:
adapter(StorageAdapter | StorageAdapterFactory): Storage adapter or storage adapter factory to use.
Adapter is a function that is called by the core persist function and has the following interface:
interface StorageAdapter {
<State>(
key: string,
update: (raw?: any) => void
): {
get(raw?: any, ctx?: any): State | undefined | Promise<State | undefined>
set(value: State, ctx?: any): void | Promise<void>
}
keyArea?: any
noop?: boolean
}key(string): Unique key to distinguish values in storage.update(Function): Function that can be called to get a value from storage. In fact, this is anEffectwith thegetfunction as a handler. In other words, any argument passed toupdatewill end up as an argument inget.
{ get, set }({ Function, Function }): Getter from and setter to storage. These functions are used as Effect handlers and can be sync or async. Also, you do not have to catch exceptions and errors inside those functions; Effects will do that for you.
As mentioned above, callingupdatetriggersgetwith the same argument. So you can handle cases whengetis called during initialpersistexecution (without arguments), or after an external update. Check out example below.
Also, getter and setter both accept an optional context as a second argument; it can be any value. This context can be useful if an adapter depends on some external environment; for example, it can contain Request and Response from Express middleware to get/set cookies from/to. (TODO: isomorphic cookies adapter example).
Adapter function can have static field keyArea; this can be any value of any type, but it should be unique for the keys namespace. For example, two local storage adapters can have different settings, but both of them use the same storage area — localStorage. So, different stores persisted in local storage with the same key (but possibly with different adapters) should be synced. That is what keyArea is responsible for. Value of that field is used as a key in the cache Map.
If it is omitted, the adapter instance is used instead.
Marks adapter as "no-op" for either function.
For example, a simplified localStorage adapter might look like this. This is an over-simplified example; do not do that in real code. There is no serialization or deserialization, and no checks for edge cases. This is just to show the idea.
import { createStore } from 'effector'
import { persist } from 'effector-storage'
const adapter = (key) => ({
get: () => localStorage.getItem(key),
set: (value) => localStorage.setItem(key, value),
})
const store = createStore('', { name: 'store' })
persist({ store, adapter }) // <- use adapterUsing asynchronous storage is just as simple. Once again, this is just a bare example, without serialization or edge-case checks. If you need to use React Native Async Storage, try the @effector-storage/react-native-async-storage adapter instead.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createStore } from 'effector'
import { persist } from 'effector-storage'
const adapter = (key) => ({
get: async () => AsyncStorage.getItem(key),
set: async (value) => AsyncStorage.setItem(key, value),
})
const store = createStore('', { name: '@store' })
persist({ store, adapter }) // <- use adapterIf your storage can be updated from an external source, then the adapter needs a way to inform/update the connected store. That is where you will need a second update argument.
import { createStore } from 'effector'
import { persist } from 'effector-storage'
const adapter = (key, update) => {
addEventListener('storage', (event) => {
if (event.key === key) {
// kick update
// this will call `get` function from below ↓
// wrapped in Effect, to handle any errors
update(event.newValue)
}
})
return {
// `get` function will receive `newValue` argument
// from `update`, called above ↑
get: (newValue) => newValue || localStorage.getItem(key),
set: (value) => localStorage.setItem(key, value),
}
}
const store = createStore('', { name: 'store' })
persist({ store, adapter }) // <- use adapterIf your storage can be updated from an external source and does not have any events to react to, but you are still able to know about updates somehow.
You can use optional pickup parameter to specify unit to trigger update (keep in mind, that when you add pickup, persist will not get initial value from storage automatically):
import { createEvent, createStore } from 'effector'
import { persist } from 'effector-storage/session'
// event, which will be used to trigger update
const pickup = createEvent()
const store = createStore('', { name: 'store' })
persist({ store, pickup }) // <- set `pickup` parameter
// --8<--
// when you are sure, that storage was updated,
// and you need to update `store` from storage with new value
pickup()Another option, if you have your own adapter, you can add this feature right into it:
import { createEvent, createStore } from 'effector'
import { persist } from 'effector-storage'
// event, which will be used in adapter to react to
const pickup = createEvent()
const adapter = (key, update) => {
// if `pickup` event was triggered -> call an `update` function
// this will call `get` function from below ↓
// wrapped in Effect, to handle any errors
pickup.watch(update)
return {
get: () => localStorage.getItem(key),
set: (value) => localStorage.setItem(key, value),
}
}
const store = createStore('', { name: 'store' })
persist({ store, adapter }) // <- use your adapter
// --8<--
// when you are sure, that storage was updated,
// and you need to force update `store` from storage with new value
pickup()I want to sync my store with
localStorage, but I need smart synchronization, not a dumb one. Each storage update should contain the last write timestamp. And when reading the value, I need to check if it has expired and fill the store with the default value in that case.
You can implement it with custom adapter, something like this:
import { createStore } from 'effector'
import { persist } from 'effector-storage'
const adapter = (timeout) => (key) => ({
get() {
const item = localStorage.getItem(key)
if (item === null) return // no value in localStorage
const { time, value } = JSON.parse(item)
if (time + timeout * 1000 < Date.now()) return // value has expired
return value
},
set(value) {
localStorage.setItem(key, JSON.stringify({ time: Date.now(), value }))
},
})
const store = createStore('', { name: 'store' })
// use adapter with timeout = 1 hour ↓↓↓
persist({ store, adapter: adapter(3600) })Both 'effector-storage/local' and 'effector-storage/session' use a common storage adapter factory. If you want to use other storage that implements the Storage interface (in fact, synchronous getItem and setItem methods are enough), you can use this factory.
import { storage } from 'effector-storage/storage'adapter = storage(options)storage(Storage): Storage to communicate with.sync? (boolean | 'force'): Add'storage'event listener or no. Default =false. In case of'force'value adapter will always read new value from Storage, instead of event.serialize? ((value: any) => string): Custom serialize function. Default =JSON.stringifydeserialize? ((value: string) => any): Custom deserialize function. Default =JSON.parse
- (StorageAdapter): Storage adapter, which can be used with the core
persistfunction.
The issue here is that it is hardly possible to create a universal mapping to/from storage for only part of a store within the library implementation. But with the persist form that uses source/target, and a little help from the Effector API, you can do it:
import { persist } from 'effector-storage/local'
const setX = createEvent()
const setY = createEvent()
const $coords = createStore({ x: 123, y: 321 })
.on(setX, ({ y }, x) => ({ x, y }))
.on(setY, ({ x }, y) => ({ x, y }))
// persist X coordinate in `localStorage` with key 'x'
persist({
source: $coords.map(({ x }) => x),
target: setX,
key: 'x',
})
// persist Y coordinate in `localStorage` with key 'y'
persist({
source: $coords.map(({ y }) => y),
target: setY,
key: 'y',
})
Use this approach with caution, beware of infinite circular updates. To avoid them, persist only plain values in storage. So, mapped store in source will not trigger update, if object in original store has changed. Also, you can take a look at updateFilter option.
- localStorage support (docs: effector-storage/local)
- sessionStorage support (docs: effector-storage/session)
- query string support (docs: effector-storage/query)
- BroadcastChannel support (docs: effector-storage/broadcast)
- AsyncStorage support (extras: @effector-storage/react-native-async-storage)
- EncryptedStorage support (extras: @effector-storage/react-native-encrypted-storage)
- IndexedDB support (extras: @effector-storage/idb-keyval)
- Cookies support
- you-name-it support