Aller au contenu principal

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')],
});