Ferry
State libraries

Jotai

@ferrysync/jotai mirrors the client into Jotai atoms: one atom per table holding its rows by id, plus atoms for the sync status and whether local data is ready. Use it when the app already reads its state through Jotai atoms.

sync-atoms.tsClient
import { createSyncAtoms } from '@ferrysync/jotai';

import { sync } from './sync';

export const syncAtoms = createSyncAtoms(sync);
Client
const TaskTitle = ({ id }: { id: string }) => {
  const tasks = useAtomValue(syncAtoms.tables.tasks, { store: syncAtoms.store });
  const pending = useAtomValue(syncAtoms.status, { store: syncAtoms.store }).pending;
  return (
    <p>
      {tasks[id]?.title} {pending > 0 ? '(saving…)' : ''}
    </p>
  );
};
bun add @ferrysync/jotai jotai

jotai (3 or later) and ferrysync are peer dependencies.

The atoms

Client
const { store, tables, status, ready } = syncAtoms;

store.get(tables.tasks)['t1']; // the task with id t1, or undefined
store.get(status).pending; // sync.status
store.get(ready); // whether sync.ready has resolved
  • tables has one atom per table: tables.projects, tables.tasks, tables.drafts. Each holds its rows by id.
  • status is sync.status, and ready is whether sync.ready has resolved. ready is false until local data is readable, again while sync.reset() runs, and stays false when storage fails to load.
  • Only what changed is set. A write sets the atom of the table it touched, and in it only the rows that changed keep their identity. Atoms of other tables keep their value, so components that read them do not re-render.
  • One change, one update per atom. A transaction over two tables sets each of those two atoms once.

Where the atoms live

The atoms live in a Jotai store of their own, syncAtoms.store, not in Jotai's default store. Read them in that store: pass it to a Provider, or to useAtomValue with the store option.

Client
const App = ({ children }: { children: ReactNode }) => (
  <Provider store={syncAtoms.store}>{children}</Provider>
);

A read without the store goes to Jotai's default store, which never hears the client. It returns the value the atom had when it was created, so always pass the store.

Writing

The atoms are a read-only mirror. Write through the client, never by setting an atom: the next change would overwrite it, and nothing would be stored or synced.

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

Options

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

The result also has destroy(), which stops the atoms following the client; they keep their last values. Atoms that live as long as the client never need it.

Without React

The atoms come from jotai/vanilla, so they work anywhere. Subscribe with the store:

Client
const stop = syncAtoms.store.sub(syncAtoms.tables.tasks, () => {
  console.log('Tasks changed:', Object.keys(syncAtoms.store.get(syncAtoms.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. A table atom is keyed by id. For filtered, sorted lists, a query with useQuery is cheaper than sorting in a derived atom on every change: its result is computed once and kept.
  • Derive narrowly. A derived atom that builds a new object or array (Object.values(…)) notifies on every change of its table; derive only what the component shows.

On this page