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.
import { createSyncAtoms } from '@ferrysync/jotai';
import { sync } from './sync';
export const syncAtoms = createSyncAtoms(sync);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 jotaijotai (3 or later) and ferrysync are peer dependencies.
The atoms
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 resolvedtableshas one atom per table:tables.projects,tables.tasks,tables.drafts. Each holds its rows by id.statusissync.status, andreadyis whethersync.readyhas resolved.readyis false until local data is readable, again whilesync.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.
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.
sync.tasks.update('t1', { done: true }); // the atoms update before this returnsOptions
const tasksOnly = createSyncAtoms(sync, { tables: ['tasks'] });
tasksOnly.tables.tasks; // typed: only `tasks` is there| Option | Default | What it does |
|---|---|---|
tables | every 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:
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 andreadyturns false, then the new data arrives. - Records, not lists. A table atom is keyed by id. For filtered, sorted lists, a
query with
useQueryis 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.