Ferry
State libraries

Zustand

@ferrysync/zustand mirrors the client into a Zustand store: every table's rows by id, the sync status, and whether local data is ready. Use it when the app already reads its state through Zustand selectors.

sync-store.tsClient
import { createSyncStore } from '@ferrysync/zustand';

import { sync } from './sync';

export const syncStore = createSyncStore(sync); // a vanilla Zustand StoreApi
Client
const TaskTitle = ({ id }: { id: string }) => {
  const title = useStore(syncStore, (state) => state.tables.tasks[id]?.title);
  const pending = useStore(syncStore, (state) => state.status.pending);
  return (
    <p>
      {title} {pending > 0 ? '(saving…)' : ''}
    </p>
  );
};
bun add @ferrysync/zustand zustand

zustand (5 or later) and ferrysync are peer dependencies.

The state

Client
type State = {
  tables: {
    projects: Record<string, Project>;
    tasks: Record<string, Task>;
    drafts: Record<string, Draft>;
  };
  status: ClientStatus; // sync.status
  ready: boolean; // whether sync.ready has resolved
};
  • Only what changed is replaced. A write replaces the record of the table it touched, and in it the rows that changed. Selectors of other tables, and of rows that did not change, return the same objects, so their components do not re-render.
  • One change, one update. A write, a transaction over several tables, a page from the server or a status change reaches subscribers once.
  • ready is false until sync.ready resolves, again while sync.reset() runs, and stays false when storage fails to load.

Writing

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

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

Options

Client
const tasksOnly = createSyncStore(sync, { tables: ['tasks'] });
tasksOnly.getState().tables.tasks; // typed: only `tasks` is there
OptionDefaultWhat it does
tablesevery table (sync.tables)The tables to mirror, by name

The store also has destroy(), which stops it following the client; its state keeps its last value. A store that lives as long as the client never needs it.

Without React

The store comes from zustand/vanilla, so it works anywhere:

Client
const stop = syncStore.subscribe((state, previous) => {
  if (state.tables.tasks !== previous.tables.tasks) {
    console.log('Tasks changed:', Object.keys(state.tables.tasks).length);
  }
});

Gotchas

  • It survives sync.reset(). It follows the client's tables, not its engine: at a sign-out the tables empty and ready turns false, then the new data arrives.
  • Records, not lists. state.tables.tasks is keyed by id. For filtered, sorted lists, a query with useQuery is cheaper than sorting in a selector on every change: its result is computed once and kept.
  • Select narrowly. A selector that builds a new object or array on every call (Object.values(state.tables.tasks)) re-renders on every change; use useShallow, or a query.

On this page