Home

Awesome

effector-storage

Build Status License NPM Made with Love

Small module for Effector ☄️ to sync stores with different storages (local storage, session storage, async storage, IndexedDB, cookies, server side storage, etc).

Table of Contents

<!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->

Install

Depending on your package manager

# using `pnpm` ↓
$ pnpm add effector-storage

# using `yarn` ↓
$ yarn add effector-storage

# using `npm` ↓
$ npm install --save effector-storage

Usage

with localStorage

Docs: 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 similar API, it much simpler and tinier.

with sessionStorage

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.

with query string

Docs: effector-storage/query

You can reflect plain string store value in query string parameter, using this adapter. Think of it like about synchronizing store value and 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.

with BroadcastChannel

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'

extra adapters

You can find a collection of useful adapters in effector-storage-extras. That side repository was created in order to not bloat effector-storage with dependencies and adapters, which depends on other libraries.

Usage with domains

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' })

Formulae

import { persist } from 'effector-storage/<adapter>'

Units

In order to synchronize something, you need to specify effector units. Depending on a requirements, you may want to use store parameter, or source and target parameters:

Options

Returns

Contracts

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)
  | {
      isData: (raw: unknown) => raw is Data
      getErrorMessages: (raw: unknown) => string[]
    }

So, it could be simple type guard function in trivial use cases, 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) 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 { Record, Literal, Number } from 'runtypes'
import { runtypeContract } from '@farfetched/runtypes'

const Asteroid = Record({
  type: Literal('asteroid'),
  mass: Number,
})

persist({
  store: $asteroid,
  key: 'asteroid',
  contract: runtypeContract(Asteroid),
})

There are two gotchas with contracts:

  1. From effector-storage point of view it is absolutely normal, when there is no persisted value in the storage yet. So, undefined value is always valid, even if contract does not explicitly allow it.
  2. effector-storage does 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, fail will be triggered, but data will be persisted.

Notes

Without specifying pickup property, calling persist will immediately call adapter to get 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 adds some logic 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.

createPersist factory

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`
})

Options

Returns

Advanced usage

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 couple of Effects and 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:

Storage adapters

Adapter is a function, which is called by the core persist function, and has following interface:

interface StorageAdapter {
  <State>(
    key: string,
    update: (raw?: any) => void
  ): {
    get(raw?: any, ctx?: any): State | Promise<State | undefined> | undefined
    set(value: State, ctx?: any): void
  }
  keyArea?: any
  noop?: boolean
}

Arguments

Returns

keyArea

Adapter function can have static field keyArea — this could be any value of any type, which should be unique for keys namespace. For example, two local storage adapters could have different settings, but both of them uses same storage arealocalStorage. 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 cache Map.<br> In case it is omitted — adapter instances is used instead.

noop

Marks adapter as "no-op" for either function.

Synchronous storage adapter example

For example, simplified localStorage adapter might looks like this. This is over-simplified example, don't do that in real code, there are no serialization and deserialization, no checks for edge cases. This is just to show an 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 adapter

Asynchronous storage adapter example

Using asynchronous storage is just as simple. Once again, this is just a bare simple idea, without serialization and edge cases checks. If you need to use React Native Async Storage, try @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 adapter

Storage with external updates example

If your storage can be updated from an external source, then adapter needs a way to inform/update connected store. That is where you will need 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 adapter

Update from non-reactive storage

If your storage can be updated from external source, and doesn't have any events to react to, but you are able to know about it 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()

Local storage adapter with values expiration

I want sync my store with localStorage, but I need smart synchronization, not dumb. Each storage update should contain last write timestamp. And on read value I need to check if value has been expired, and fill store with 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) })

Custom Storage adapter

Both 'effector-storage/local' and 'effector-storage/session' are using common storage adapter factory. If you want to use other storage, which implements 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)

Options

Returns

FAQ

Can I persist part of the store?

The issue here is that it is hardly possible to create universal mapping to/from storage to the part of the store within the library implementation. But with persist form with source/target, and little help of Effector API you can make 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',
})

⚠️ BIG WARNING!<br> 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.

TODO

Sponsored

<img src="https://setplex.com/img/logo.png" alt="Setplex OTT Platform" width="236">

Setplex OTT Platform