Ferry
State libraries

Legend-State

@ferrysync/legend-state mirrors the client's tables into Legend-State observables. Use it when the app already reads its state through observables, with fine-grained re-renders.

store.tsClient
import { observable } from '@legendapp/state';
import { bindLegendState, bindTable } from '@ferrysync/legend-state';

import type { Project, Task } from './schema';
import { sync } from './sync';

export const store$ = observable<{
  projects: Record<string, Project>;
  tasks: Record<string, Task>;
}>({ projects: {}, tasks: {} });

bindLegendState(sync, [
  bindTable(sync.projects, store$.projects),
  bindTable(sync.tasks, store$.tasks),
]);
Client
const TaskTitle = observer(({ id }: { id: string }) => {
  const title = useValue(() => store$.tasks[id]?.title.get());
  return <p>{title}</p>;
});
bun add @ferrysync/legend-state @legendapp/state

@legendapp/state (3, from 3.0.0-beta.48) and ferrysync are peer dependencies.

Bindings

FunctionWhat it does
bindTable(table, target$)Mirrors every row of table into target$, an observable of rows by id
bindRow(table, target$, id)Mirrors the row id into target$, an observable of one row or null
bindLegendState(sync, bindings)Fills every binding, follows the client's changes, and returns the function to stop

bindRow mirrors one row: the project a screen shows, or a settings row with a known id.

Client
const current$ = observable<Project | null>(null);
const stop = bindLegendState(sync, [bindRow(sync.projects, current$, projectId)]);

How it behaves

  • One change, one batch. Each change of the client is applied in one Legend-State batch(), so observers run once, however many tables it touched. Status changes write nothing.
  • Only the rows that changed are written (target$[id].set(row), target$[id].delete()). The record and the other rows keep their objects, and only those rows' observers hear of it.
  • It survives sync.reset(). It reads the client's tables, not its engine: at a sign-out the observables empty with the tables, then fill with the new data.
  • Types are checked. An observable of another row type, or of a wider one, is refused.

Writing

The observables are a read-only mirror. Write through the client, never with set: the next change would overwrite it, and nothing would be stored or synced.

Client
sync.tasks.update('t1', { done: true }); // store$.tasks.t1 updates before this returns

The engine form

Apps built on the low-level engine (createSyncEngine) bind models instead of tables, with bindEngine(engine, [bindModel(model, target$), bindSingleton(model, target$, id)], { onInvalidRow }). It follows one engine, so it does not survive sync.reset(), which replaces sync.engine: bind the client with bindLegendState whenever there is one.

Gotchas

  • Bind once, next to the store, for the app's lifetime. Each bindLegendState call subscribes again; call the function it returns to stop one.
  • Bind before reading. The binding fills the observables when it is made; a binding made before sync.ready fills them as the data loads.

On this page