React
ferrysync/react connects components to the client: a provider that waits for local data, and hooks
that re-render only when what they read changes. It works the same on React 18 and 19, in the
browser and on React Native.
export const App = () => (
<SyncProvider sync={sync} fallback={<Splash />}>
<TaskList projectId="p1" />
</SyncProvider>
);const OpenTasks = ({ projectId }: { projectId: string }) => {
const tasks = useQuery(sync.tasks.where({ projectId, done: false }).orderBy('title'));
return (
<ul>
{tasks.map((task) => (
<li key={task.id} onClick={() => sync.tasks.update(task.id, { done: true })}>
{task.title}
</li>
))}
</ul>
);
};Reads go through hooks; writes are plain calls on the client, in event handlers. There is no loading state for data: it is on the device.
react (18 or later) is an optional peer dependency of ferrysync, needed only by this entry.
react-dom is never imported.
SyncProvider
<SyncProvider sync={sync} fallback={<Splash />}>
<TaskList projectId="p1" />
</SyncProvider>| Prop | Type | What it does |
|---|---|---|
sync | the client | The client the hooks below use |
fallback | ReactNode | Rendered until sync.ready resolves. Default: nothing |
children | ReactNode | Rendered once local data is readable and writes work |
- It waits for
sync.ready, so the screens below it never read before storage has loaded, nor write before writes work. That takes a few milliseconds at startup. - After
sync.reset()(sign-out), it renders the fallback again until the new start is ready: the screens of the previous account unmount, and the app starts over. - When storage fails to load, it throws the error for the nearest error boundary: see Error boundary.
- It reads
sync.isReadywithuseSyncExternalStore, not Suspense. A client that is already ready renders the children on the first render, with no fallback in between.
Error boundary
SyncProvider rethrows what rejects sync.ready. The one to expect on the web is
StorageLockedError (code: 'locked'): the app is already open in another tab, and the web
storages allow one tab at a time. Catch it with an error
boundary above the provider and tell the user, instead of showing a blank screen.
import { Component, type ReactNode } from 'react';
import { StorageLockedError } from 'ferrysync';
import { SyncProvider } from 'ferrysync/react';
import { sync } from './sync';
declare function TaskList(props: { projectId: string }): ReactNode;
class SyncBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
override state: { error: unknown } = { error: null };
static getDerivedStateFromError(error: unknown) {
return { error };
}
override render() {
const { error } = this.state;
if (error instanceof StorageLockedError) {
return <p>This app is open in another tab. Close it and reload.</p>;
}
if (error !== null) {
return <p>Could not open your data.</p>;
}
return this.props.children;
}
}
export const App = () => (
<SyncBoundary>
<SyncProvider sync={sync} fallback={null}>
<TaskList projectId="p1" />
</SyncProvider>
</SyncBoundary>
);With { onLocked: 'wait' } on the storage, the provider shows its fallback until the other tab is
closed, and no error is thrown.
useQuery
The current result of a query, re-rendering only when it changes. It reads the
local database and never fetches: the result is there on the first render, with no loading state.
useLiveQuery is the same hook under another name, for apps that also use
TanStack Query or SWR.
const Projects = () => {
const projects = useQuery(sync.projects.all().orderBy('name'));
const first = useQuery(sync.projects.all().orderBy('name').first()); // a project or null
return (
<p>
{projects.length} projects, the first is {first?.name ?? 'none'}
</p>
);
};A query made of object filters, orders and limits is the same object every time it is written, so it
can be inline, as above. A predicate query is new on every call: pass a function and its
dependencies instead, as with useMemo.
const Search = ({ search }: { search: string }) => {
const found = useQuery(
() => sync.tasks.where((task) => task.title.toLowerCase().includes(search.toLowerCase())),
[search],
);
return <p>{found.length} found</p>;
};useRow
One row by id, or null. It re-renders when that row is created, changes or is deleted, and not for
other rows.
const TaskTitle = ({ id }: { id: string | null }) => {
const task = useRow(sync.tasks, id); // a null or undefined id reads null
return <h1>{task?.title ?? 'No task selected'}</h1>;
};useSyncStatus
The client's status, re-rendering when it changes.
const SaveIndicator = () => {
const { synced, online, pending } = useSyncStatus();
if (synced) {
return <span>Saved</span>;
}
return <span>{online ? `Saving ${pending}…` : 'Offline'}</span>;
};useUndo
Undo and redo, with canUndo and canRedo that
re-render only when they change. undo and redo keep their identity.
const UndoButton = () => {
const { undo, canUndo } = useUndo();
return (
<button type="button" disabled={!canUndo} onClick={undo}>
Undo
</button>
);
};useSync
The provider's client. Most apps import their client directly; useSync is for components that
should not, such as ones a library ships or a test renders with another client.
const ProjectCount = () => {
const client = useSync();
return <p>{useQuery(client.projects.all()).length} projects</p>;
};useSyncStatus(sync) and useUndo(sync) also take the client as an argument, for use outside a
provider. useQuery and useRow take their query or table, so they need no provider at all.
Typing useSync
useSync returns your client typed once you register it, next to the client:
import { createSync } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({ schema });
declare module 'ferrysync/react' {
interface Register {
sync: typeof sync;
}
}Without it, useSync() returns a client without tables. Registered, SyncProvider also refuses a
client of another schema.
Hooks at a glance
| Hook | Returns | Re-renders when |
|---|---|---|
useQuery(query) | the query's result | the result changes |
useQuery(make, deps) | the result of the query make returns | the result changes, or deps do |
useLiveQuery(…) | the same as useQuery | the same |
useRow(table, id) | the row, or null | that row changes |
useSyncStatus(sync?) | sync.status | the status changes |
useUndo(sync?) | { undo, redo, canUndo, canRedo } | canUndo or canRedo changes |
useSync() | the provider's client | never |
All of them read with useSyncExternalStore, from snapshots that stay the same object until they
change: no effects, no tearing in concurrent rendering, and rows that did not change keep their
objects, so memo children skip their render.
With TanStack Query or SWR
They do different jobs, so an app can use both. TanStack Query and SWR fetch data that lives on a server, cache it, and say when it is loading. Ferry's data already lives on the device: there is nothing to fetch, so there is no loading state and no cache to invalidate.
| Data | Read it with |
|---|---|
| Rows the app creates, edits and syncs | Ferry |
| Data from another API (weather, payments, search) | TanStack Query or SWR |
| Numbers the server computes from synced rows (a report) | TanStack Query or SWR, see below |
Both libraries call their hook useQuery too, so import Ferry's as useLiveQuery:
import { useQuery } from '@tanstack/react-query';
import { useLiveQuery } from 'ferrysync/react';
import { sync } from './sync';
export const Trip = ({ projectId }: { projectId: string }) => {
const tasks = useLiveQuery(sync.tasks.where({ projectId })); // local: never loading
const weather = useQuery({ queryKey: ['weather', projectId], queryFn: () => fetchWeather() });
return (
<p>
{tasks.length} tasks · {weather.isPending ? '…' : weather.data}
</p>
);
};- Do not wrap Ferry's reads in a fetching library. It adds a second cache that every write and every change from another device would have to invalidate, and a loading state for data that is already in memory.
- Do not write through mutations.
sync.tasks.update(…)applies at once and queues itself offline; a mutation would add a pending state to something that never waits. - For server-computed data, key it on
lastSyncId. It grows only when the device catches up with the server's log, so the query refetches when the server may have news, and not otherwise. See TanStack Query and SWR.
React Compiler
The hooks need no manual memoization, and the React Compiler needs nothing from you.
- Inline query chains are fine: the same chain is the same query.
- Keep the dependencies of
useQuery(make, deps): they say when to build a new query, which the compiler cannot know. To have your linter check them, add the hook toexhaustive-deps:"react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "(useQuery)" }](ESLint), and the same option in oxlint'sreact/exhaustive-deps.
React Native
-
Nothing to configure: the package has no native code and runs on Hermes.
-
Pick a native storage:
sqliteStoragewith expo-sqlite, ormmkvStorage. -
Ids need no polyfill. Without Web Crypto, their random part comes from
Math.random, which is enough for ids. -
Sync when the app comes back to the foreground, since the OS pauses the network in the background:
import \{ AppState \} from 'react-native'; AppState.addEventListener('change', (state) => \{ if (state === 'active') \{ void sync.syncNow(); \} \}); -
Keep the client in a module of its own (
sync.ts). Fast Refresh re-runs a module you edit, and a second client over the same storage is trouble: reload the app after editing that module.
Gotchas
- Write in handlers, not in render. Writes notify every subscriber synchronously, so a write during render updates other components mid-render. Effects are fine.
- Predicate queries need the factory form.
useQuery(sync.tasks.where((t) => …))builds a new query on every render: the hook still works, but re-subscribes each time and returns a new array. - Server rendering renders the fallback:
sync.readynever resolves during a server render.
Testing
The client needs no server and no device to test: give it memory storage and the local() backend, which are the defaults, and it runs anywhere your tests do.
The server
Ferry's server is a library you mount in your backend, not a service you deploy. It runs on one route of your existing app, on your database, behind your auth.