Sync Atom Effect - syncEffect()
syncEffect()
is an atom effect is used to tag atoms that should be synchronized and have them initialize their value with the external store. The only required option is refine
for input validation. The itemKey
option allows you to specify a key for this particular atom with the external store. If not specified, it defaults to the atom's own key. A storeKey
can also be provided to match up which external store to sync with, if you have more than one. There are additional options, such as read
and write
for more advanced cases.
Input Validationโ
To validate the input from the external system and refine from mixed
to a strongly typed Flow or TypeScript input, recoil-sync
uses the Refine library. This library uses a set of composable functions to describe the type and perform runtime validation. The refine
property of syncEffect()
takes a Refine Checker
. The type of the Refine checker must match the type of the atom.
Example effect for a simple string atom:
syncEffect({ refine: string() }),
Example effect for a nullable number:
syncEffect({ refine: nullable(number()) }),
Custom user class:
syncEffect({ refine: custom(x => x instanceof MyClass ? x : null) }),
More complex example:
syncEffect({ refine: object({
id: number(),
friends: array(number()),
positions: dict(tuple(bool(), number())),
})}),
See the Refine documentation for details.
Item and Store Keysโ
The itemKey
specifies a unique key to identify the item for the store, if not specified it defaults to the atom's key. If a custom read()
or write()
is used then it can override the item key to upgrade or use multiple item keys.
A storeKey
can be used to specify which external store to sync with. It should match up with the storeKey
for the cooresponding <RecoilSync>
. This is useful when upgrading or if there are more than one store.
atom({
key: 'AtomKey',
effects: [
syncEffect({
itemKey: 'myItem',
storeKey: 'storeA',
refine: string(),
}),
],
});
Atom Familiesโ
Atoms in an atom family can also by synchronized with syncEffect()
. Each individual atom in the family is treated as a separate item to sync. The default item key will include a serialization of the family parameter. If you specify your own itemKey
then you should also encode the family parameter to uniquely identify each atom; the parameter can be obtained by using a callback for the atom family effects
option.
atomFamily({
key: 'AtomKey',
effects: param => [
syncEffect({
itemKey: `myItem-${param}`,
storeKey: 'storeA',
refine: string(),
}),
],
});
Backward Compatibilityโ
It can be important to support legacy systems or external systems with previous versions of state. There are several mechanisms available for this
Upgrade atom typeโ
If an atom was persisted to a store and you have since changed the type of the atom, you can use Refine's match()
and asType()
to upgrade the type. This example reads an ID that is currently a number but was previously stored as a string or an object. It will upgrade the previous types and the atom will always store the latest type.
const myAtom = atom<number>({
key: 'MyAtom',
default: 0,
effects: [
syncEffect({ refine: match(
number(),
asType(string(), x => parseInt(x)),
asType(object({value: number()}), x => x.value)),
}),
],
});
Upgrade atom keyโ
The atom's key may also change over time. The read
option allows us to specify how to read the atom from the external store
const myAtom = atom<number>({
key: 'MyAtom',
default: 0,
effects: [
syncEffect({
itemKey: 'new_key',
read: ({read}) => read('new_key') ?? read('old_key'),
}),
],
});
More complex transformations when reading are possible, see below.
Upgrade atom storageโ
You can also migrate an atom to sync with a new external store using multiple effects.
const myAtom = atom<number>({
key: 'MyAtom',
default: 0,
effects: [
syncEffect({ storeKey: 'old_store', refine: number() }),
syncEffect({ storeKey: 'new_store', refine: number() }),
],
});
Syncing with Multiple Storagesโ
It may be desirable for an atom to always sync with multiple storage systems. For example, an atom for some UI state may want to persist the current state for a shareable URL while also syncing with a per-user default stored in the cloud. This can be done simply by composing multiple atom effects (you can mix-and-match using syncEffect()
or other atom effects). The effects are executed in order, so the last one gets priority for initializing the atom.
const currentTabState = atom<string>({
key: 'CurrentTab',
default: 'FirstTab', // Fallback default for first-use
effects: [
// Initialize default with per-user default from the cloud
syncEffect({ storeKey: 'user_defaults', refine: string() }),
// Override with state stored in URL if reloading or sharing
syncEffect({ storeKey: 'url', refine: string() }),
],
});
Abstract Storesโ
The same atom might also sync with different storages depending on the host environment. For example:
const currentUserState = atom<number>({
key: 'CurrentUser',
default: 0,
effects: [
syncEffect({ storeKey: 'ui_state', refine: number() }),
],
});
A standalone app might sync that atom with the URL:
function MyStandaloneApp() {
return (
<RecoilRoot>
<RecoilURLSyncTransit storeKey="ui_state" location={{part: 'hash'}}>
...
</RecoilURLSyncTransit>
</RecoilRoot>
);
}
While another app that uses components which use the same atom might want to sync it with local storage:
function AnotherApp() {
return (
<RecoilRoot>
<RecoilSyncLocalStorage storeKey="ui_state">
...
</RecoilSyncLocalStorage>
</RecoilRoot>
)
}
Advanced Atom Mappingsโ
Atoms may not map to items in the external store one-to-one. This example describes using read
to implement a key upgrade. The read
and write
options for syncEffect()
can be used to implement more complex mappings.
Care must be taken with advanced mappings as there could be ordering issues, atoms may try to overwrite the same items, etc.
Many-to-oneโ
Example effect for an atom that pulls state from multiple external items:
function manyToOneSyncEffect() {
syncEffect({
refine: object({ foo: nullable(number()), bar: nullable(number()) }),
read: ({read}) => ({foo: read('foo'), bar: read('bar')}),
write: ({write, reset}, newValue) => {
if (newValue instanceof DefaultValue) {
reset('foo');
reset('bar');
} else {
write('foo', newValue.foo);
write('bar', newValue.bar);
}
},
});
}
atom<{foo: number, bar: number}>({
key: 'MyObject',
default: {},
effects: [manyToOneSyncEffect()],
});
One-to-manyโ
Example effect that pulls state from a prop in a compound external object:
function oneToManySyncEffect(prop: string) {
const validate = assertion(dict(nullable(number())));
syncEffect({
refine: nullable(number()),
read: ({read}) => validate(read('compound'))[prop],
write: ({write, read}, newValue) => {
const compound = {...validate(read('compound'))};
if (newValue instanceof DefaultValue) {
delete compound[prop];
write('compound', compound);
} else {
write('compound', {...compound, [prop]: newValue});
}
},
});
}
atom<number>({
key: 'MyNumber',
default: 0,
effects: [oneToManySyncEffect('foo')],
});