effector-storage v6

yumauri

Victor Didenko

Posted on May 8, 2023

effector-storage v6

Wow, that was a long jump! But now effector-storage version 6 is landed.

So, whats new?

Contracts support

This could be a minuscule change for a majority of this library users, but I consider this like a big step toward serious business. "Contract" sounds serious, doesn't it?

Like any source, which your code has no full control on, localStorage values should never be trusted and needs to be validated. Any script kiddie can open DevTools and change localStorage value, thus breaking your big-money application. In order to prevent it, data should be validated in two levels — structurally and businessly. effector-storage library cannot help you with your business logic, but at least, it can validate values against contracts now.

In a simple case, contract could be just a simple type guard:

persist({
  store: $counter,
  key: 'counter',
  contract: (raw): raw is number => typeof raw === 'number',
  fail: failed
})
Enter fullscreen mode Exit fullscreen mode

So, if localStorage somehow will contain a string now — persist will fail restoring store $counter, and trigger failed event with operation: "validate".

Note though, that contracts are working after parsing (deserializing), so if localStorage value is not a valid JSON string at all — persist will fail, triggering failed event with operation: "get".

In more complex cases, when you need to validate data against some complex structure with complex rules — effector-storage fully supports Farfetched contracts, so you can use any adapter from Farfetched — runtypes, zod, io-ts, superstruct, typed-contracts:

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),
  fail: failed
})
Enter fullscreen mode Exit fullscreen mode

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.

Exported adapters

Sometimes it is required to persist different stores within different storages within the same module. And to avoid names clashes in version 5 you have to write something like this:

import { persist as localPersist } from 'effector-storage/local'
import { persist as queryPersist } from 'effector-storage/query'

localPersist({ store: $session })
queryPersist({ store: $page })
Enter fullscreen mode Exit fullscreen mode

In version 6 all adapters now are exported alongside with persist function, and could be used on their own. More over, all (major) adapters are exported from the root package as well, so you can use them all in once with a single import statement:

import { persist, local, query } from 'effector-storage'

persist({ adapter: local, store: $session })
persist({ adapter: query, store: $page })
Enter fullscreen mode Exit fullscreen mode

Strictly speaking, imported local and query in the example above are not adapters, but adapter factories. So you can call factory to create an adapter instance, if you need some adapter adjustments:

persist({
  store: $date,
  adapter: local({
    serialize: (date) => String(date.getTime()),
    deserialize: (timestamp) => new Date(Number(timestamp)),
  }),
})
Enter fullscreen mode Exit fullscreen mode

Or you can pass any adapter factory arguments right into persist!

persist({
  store: $date,
  adapter: local,
  serialize: (date) => String(date.getTime()),
  deserialize: (timestamp) => new Date(Number(timestamp)),
})
Enter fullscreen mode Exit fullscreen mode

Adapter utilities

With exported adapters new idea has crossed my mind: what if you could write a high order function, which will accept adapter (or adapter factory), and slightly change it behaviour?

So, I made a few:

async utility function

It takes any synchronous storage adapter (or factory) and makes it asynchronous:

persist({
  adapter: async(local),
  store: $counter,
  done: doneEvent,
})
Enter fullscreen mode Exit fullscreen mode

This could be useful, when you need to postpone store restoring a bit. If you don't use pickup option, persist begins restoring store state right at the moment. So, in case of synchronous localStorage, right after persist is executed — store value already be restored from the storage (of course, if it was persisted beforehand). Sometimes it can affect your business logic, for example, if you need to react on store restoring, you need to take that behaviour into account, and define all connections (like samples) before you call persist.

async utility function can help you with that, freeing you from that burden of keeping declarations order in mind.

either utility function

It takes two adapters (or factories), and returns first of them, which is not "no-op".

What the heck is "no-op" adapter?

Version 6 introduces two separate "terms", or "states" for adapters:

  • Supported — this means, that storage is supported by environment, for example, localStorage is supported in browsers, but not supported in Nodejs.
  • Available — this means, that storage is supported and available, for example, localStorage could be forbidden by browser security policies.

So, if adapter is doing nothing (like nil adapter), or it is not supported — this is "no-op" adapter.

persist({
  store: $store,
  adapter: either(local, log),
  key: 'store'
})
Enter fullscreen mode Exit fullscreen mode

In the example above store $store will be persisted in localStorage in the browser, and will use new log adapter, which just prints operations, in Nodejs.

Breaking change❗️ In version 5 local adapter was behaving like not supported (and did nothing) when localStorage is forbidden by browser security policies. In version 6 this considered like not available, thus, local adapter will trigger security error on each operation. You can handle this and ask user to enable localStorage, for example.

farcached utility function

It takes any cache adapter from Farfetched and converts it to be used as persist adapter :)

This one I made mostly out of fun and to show possibilities, which opens with utility functions approach.

From real usage point of view, using Farfetched cache adapters could be useful, when you need logic for cache invalidation, because all of its adapters have maxAge option out of the box.

Also, you could use Farfetched cache adapters to inject different cache adapters with fork using cache.__.$instance internal store.

import { localStorageCache } from '@farfetched/core'

persist({
  store: $counter3,
  adapter: farcached(localStorageCache({ maxAge: '15m' })),
})
Enter fullscreen mode Exit fullscreen mode

Adapter context

This is an experimental feature, and I think it will be like dark horse for a while 🤫 But I plan to show it fully in next minor releases.

In a few words, context is aimed for two main purposes:

  • Asynchronous updates from storage in scope
  • Using different environments in adapter

You can pass context in two ways:

  • As pickup event payload
  • As new context event payload

persist will remember scope, in which you have called pickup or context, and will use this scope for any asynchronous updates from storage (like on "storage" event).

Also, context will be passed as a second argument in adapter's get and set functions, and adapter can use it as it pleases.

Force sync mode

Due to minor performance optimizations, when local adapter receives "storage" event — it takes new storage value from this event's payload. Obviously, there is no need to read (relatively slow) localStorage when new value is right here, inside the already received event.

But in some cases this approach could lead to stores desynchronization with simultaneous or almost simultaneous updates from different tabs. You can call this race condition.

This happens because "storage" event is happening only when storage is modified from another tab/window. So, in any situation with simultaneous or almost simultaneous localStorage updates from more than one tab — there always will be a loser tab, which will become unsynchronized. I've drawn an image with two tabs: each tab receives update from another tab, and one of the tabs inevitably become unsynchronized with localStorage:

stores desynchronization example

I consider this very rare case, so, version 6 does not change default behaviour (with using new value from event), but adds new possible value for sync option instead — 'force':

persist({
  store: $balance,
  key: 'balance',
  sync: 'force'
})
Enter fullscreen mode Exit fullscreen mode

This will ignore new value from "storage" event, and will forcibly read new value from localStorage instead, thus eliminating desynchronization issue.

Throttle/batch writes

Version 6 adds new option timeout for three adapters: local, session and query. It behaves slightly differently for them, but meaning is plain — postpone writes to a storage.

For local and session (and base storage) adapters timeout option behaves like throttle for the single persist (without leading write). So, if you have store, which updates frequently and persisted in the localStorage, you can slightly improve performance by throttling access to the localStorage with this option.

For query adapter timeout option behaves like throttle (without leading write) and batch. So, if you have many stores, which are persisted in a query string, you can use this option to throttle and batch the query string updates.

Drop Effector v21 support

Version 5 fully supports Effector v21, which was out on July 6, 2020 — almost three years ago. This is possible, because effector-storage uses only base Effector's functionality, and because Effector has an excellent backward compatibility.

But times go on, and Effector v22 has been out on August 31, 2021 — almost two years ago. effector-storage already has some small tweaks to support different versions and new features, so, I think, it is time to stop supporting version 21.

it's time

Also, Nodejs v14 has reached end-of-life on April 30, 2023 — so, version 6 also drops it. And so should you ;)

That is all I got for you today, stay tuned ;)

💖 💪 🙅 🚩
yumauri
Victor Didenko

Posted on May 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related