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.
import { createSyncStore } from '@ferrysync/zustand';
import { sync } from './sync';
export const syncStore = createSyncStore(sync); // a vanilla Zustand StoreApiconst 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 zustandzustand (5 or later) and ferrysync are peer dependencies.
The state
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.
readyis false untilsync.readyresolves, again whilesync.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.
sync.tasks.update('t1', { done: true }); // the store updates before this returnsOptions
const tasksOnly = createSyncStore(sync, { tables: ['tasks'] });
tasksOnly.getState().tables.tasks; // typed: only `tasks` is there| Option | Default | What it does |
|---|---|---|
tables | every 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:
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 andreadyturns false, then the new data arrives. - Records, not lists.
state.tables.tasksis keyed by id. For filtered, sorted lists, a query withuseQueryis 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; useuseShallow, or a query.