Ferry

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.

Client
export const App = () => (
  <SyncProvider sync={sync} fallback={<Splash />}>
    <TaskList projectId="p1" />
  </SyncProvider>
);
Client
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

Client
<SyncProvider sync={sync} fallback={<Splash />}>
  <TaskList projectId="p1" />
</SyncProvider>
PropTypeWhat it does
syncthe clientThe client the hooks below use
fallbackReactNodeRendered until sync.ready resolves. Default: nothing
childrenReactNodeRendered 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.isReady with useSyncExternalStore, 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.

Client
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.

Client
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.

Client
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.

Client
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.

Client
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.

Client
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.

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:

sync.tsClient
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

HookReturnsRe-renders when
useQuery(query)the query's resultthe result changes
useQuery(make, deps)the result of the query make returnsthe result changes, or deps do
useLiveQuery(…)the same as useQuerythe same
useRow(table, id)the row, or nullthat row changes
useSyncStatus(sync?)sync.statusthe status changes
useUndo(sync?){ undo, redo, canUndo, canRedo }canUndo or canRedo changes
useSync()the provider's clientnever

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.

DataRead it with
Rows the app creates, edits and syncsFerry
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:

Client
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 to exhaustive-deps: "react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "(useQuery)" }] (ESLint), and the same option in oxlint's react/exhaustive-deps.

React Native

  • Nothing to configure: the package has no native code and runs on Hermes.

  • Pick a native storage: sqliteStorage with expo-sqlite, or mmkvStorage.

  • 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.ready never resolves during a server render.

On this page