effector-storage v6
Victor Didenko
Posted on May 8, 2023
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
})
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
})
There are two gotchas with contracts:
- 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. -
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 })
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 })
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)),
}),
})
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)),
})
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,
})
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 sample
s) 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'
})
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' })),
})
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
:
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'
})
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.
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 ;)
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
November 30, 2024