<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, TanStack Query, and TanStack DB
- [Inspired by](/inspired-by): the libraries and ideas rxfy grew out of
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully synced
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Create Store](/getting-started/create-store): Normalized reactive state in a client-only app
- [Add SSR](/getting-started/add-ssr): Render the first paint on the server, hydrate with no refetch
- [Add Sync Client](/getting-started/add-sync-client): The full stack: the server publishes, the client syncs
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [Observables](/core-concepts/observables): A value that changes over time, that you can subscribe to
- [Normalization](/core-concepts/normalization): Store each entity once, reference it by id
- [Late Unwrapping](/core-concepts/late-unwrapping): Unwrap async state at the leaf, not the trunk
- [Server-Side Rendering](/core-concepts/ssr): Dehydrate on the server, hydrate with no refetch
- [rxfy](/rxfy): The core package: atoms, lenses, models, and states
- [createModel](/rxfy/create-model): Typed entities in normalized storage
- [defineState](/rxfy/define-state): Typed, normalized state shapes with fetching and mutations
- [createAtom](/rxfy/create-atom): A reactive cell with synchronous get, set, and modify
- [createLens](/rxfy/create-lens): A two-way view into part of an Atom
- [React Bindings](/react): Hooks and helpers for using rxfy in React
- [useStateData](/react/use-state-data): Fetch, normalize, and subscribe to a query
- [useStatePagedData](/react/use-state-paged-data): Paginated and infinite-scroll lists
- [useModelStore](/react/use-model-store): Subscribe to one normalized entity by id
- [useAtom](/react/use-atom): Two-way binding for any IAtom
- [Pending](/react/pending): Render pending, rejected, and fulfilled UI for any observable
- [usePending](/react/use-pending): The status value behind Pending
- [useObservable](/react/use-observable): Bind a raw Observable to React
- [Sync Client in React](/react/sync-client): StoreProvider, useSyncClient, and update handles
- [rxfy-client](/framework/client): The framework-agnostic browser sync runtime
- [createSyncClient](/framework/client/create-sync-client): Connect a transport and drive the sync loop
- [readSsrGrants](/framework/client/read-ssr-grants): Lift SSR-embedded channel grants
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish sync updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createSync](/framework/server/create-server): Wire a storage adapter, hub, and secret into a Live object
- [createInMemoryHub](/framework/server/hub): The socket-keyed pub/sub backbone
- [Writes](/framework/server/writes): sync.create / sync.update / sync.delete and touch
- [Storage adapters](/framework/server/storage-adapters): Persist writes with Drizzle or in memory
- [Sync messages](/framework/server/messages): What travels between server and client
- [Grants](/framework/server/grants): The server signs what it serves; the client subscribes with the token
- [rxfy-ws](/framework/ws): The default WebSocket transport
- [createWsServer](/framework/ws/server): Attach a Hub to WebSocket connections
- [createWsClient](/framework/ws/client): The browser transport with reconnect and replay
- [Custom transports](/framework/ws/custom-transport): Bring your own ClientTransport
- [Guides](/guides): Task-focused walkthroughs of common rxfy patterns
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
-->

# Add Sync Client \[The full stack: the server publishes, the client syncs]

This is the third level. On top of your [store](/getting-started/create-store)
and [SSR](/getting-started/add-ssr), a **Sync Client** keeps every connected browser in
sync in real time. The server owns writes: it applies a change, then publishes the new
entity value automatically; every client writes that value into its shared store, and every
component subscribed to that entity re-renders. Same store, same components — nothing in
`useStateData` or `useModelStore` is sync-aware — the write just arrives and propagates to the view-layer.

This page wires the whole loop: the server, the socket, writes, and the browser Sync Client,
then watches an edit land in another tab. Prefer to start from a working app? Scaffold one
with [`create-rxfy-app`](/getting-started) (the `vite` template is this exact stack).

:::note\[Is this level for you?]
The Sync Client fits best when you're starting a new app, or when your backend runs on Node.
`rxfy-server` reaches your database through a **storage adapter**, so it binds synced resources
directly to your tables. [Drizzle](https://orm.drizzle.team) (via `rxfy-server-drizzle`) is the
adapter shown here, with more data providers on the way. On a stack no adapter covers yet, stay
on the [Create Store](/getting-started/create-store) level and fetch your API your way — you can
still [Add SSR](/getting-started/add-ssr) without the sync stack.
:::

## Install

Everything from the store path plus the sync stack: `rxfy-server` (writes and
publishing), `rxfy-ws` (the WebSocket transport), and `rxfy-client` (the framework-agnostic
browser sync runtime — grant subscription and renewal, and the real-time sync sink), with
[Drizzle](https://orm.drizzle.team) and `ws` as their peers.
The wire contract between them (see [Sync messages](/framework/server/messages)) comes
along as an internal dependency.

:::code-group

```bash [npm]
npm install rxfy rxfy-client rxfy-react rxfy-server rxfy-server-drizzle rxfy-ws
npm install rxjs zod lodash drizzle-orm drizzle-zod ws
```

```bash [pnpm]
pnpm add rxfy rxfy-client rxfy-react rxfy-server rxfy-server-drizzle rxfy-ws
pnpm add rxjs zod lodash drizzle-orm drizzle-zod ws
```

```bash [yarn]
yarn add rxfy rxfy-client rxfy-react rxfy-server rxfy-server-drizzle rxfy-ws
yarn add rxjs zod lodash drizzle-orm drizzle-zod ws
```

```bash [bun]
bun add rxfy rxfy-client rxfy-react rxfy-server rxfy-server-drizzle rxfy-ws
bun add rxjs zod lodash drizzle-orm drizzle-zod ws
```

:::

## Bind tables to models

Three small files: the Drizzle table, the rxfy model that governs it, and the resource that
pairs them. The model's Zod schema is derived straight from the table with `drizzle-zod`, so
the shape is defined once; the model's `name` becomes the sync topic namespace and its `getKey`
locates the primary key; `defineResource` (from `rxfy-server-drizzle`) binds the two:

:::code-group

```ts [src/resources.ts]
import { defineResource } from "rxfy-server-drizzle";
import { postModel } from "./models.js";
import { posts } from "./db/schema.js";

export const postResource = defineResource({
  table: posts,
  model: postModel,
});
```

```ts [src/db/schema.ts]
import { pgTable, text } from "drizzle-orm/pg-core";

// Ordinary Drizzle — nothing rxfy-specific here.
export const posts = pgTable("posts", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  body: text("body").notNull(),
});
```

```ts [src/models.ts]
import { createModel } from "rxfy";
import { createSelectSchema } from "drizzle-zod";
import { posts } from "./db/schema.js";

// `name` is the sync topic namespace; `getKey` locates the entity id.
export const postModel = createModel({
  // Derive the Zod schema straight from the table — the shape is defined once.
  schema: createSelectSchema(posts),
  getKey: (post) => post.id,
  name: "post",
});
```

:::

## Stand up the sync server

`createSync` wires a storage adapter, a pub/sub hub, and a signing `secret` into the `live`
object your API routes will call. `drizzleStorage(db)` (from `rxfy-server-drizzle`) is the
adapter for a Drizzle-backed app:

```ts [server/live.ts]
import { createInMemoryHub, createSync } from "rxfy-server";
import { drizzleStorage } from "rxfy-server-drizzle";
import { db } from "./db.js";

export const sync = createSync({
  storage: drizzleStorage(db),
  hub: createInMemoryHub(),
  secret: process.env.RXFY_SECRET ?? "dev-secret-change-me",
});
```

* `createInMemoryHub` — the pub/sub backbone for one process, keyed by **socket**; swap it
  for a distributed adapter when you scale out.
* `secret` is the HMAC key used to sign and verify grants. Read it from
  `process.env.RXFY_SECRET`, and share the exact same value with the WebSocket server.
* There is no keyer and no server-held session. The server signs a per-state grant on
  `sync.serve` / `sync.hydration`, and the client subscribes with it — see
  [Grants](/framework/server/grants).

## Serve the socket

`createWsServer(hub, { secret })` speaks the subscription protocol; a plain `ws` socket
satisfies its connection interface directly. It verifies each `subscribe` frame's grant with
the **same** `secret` the HTTP server signs with:

```ts [server/ws.ts]
import { WebSocketServer } from "ws";
import { createWsServer } from "rxfy-ws";
import { hub, SECRET } from "./sync.js";

const wsServer = createWsServer(hub, { secret: SECRET });
// `httpServer` is the node:http server your app already listens on
const wss = new WebSocketServer({ server: httpServer, path: "/live" });

wss.on("connection", (socket) => wsServer.handleConnection(socket));
```

Framework servers that wrap the raw socket (Hono, Bun) need a thin adapter — see
[rxfy-ws](/framework/ws) for the Hono bridge.

## Write through the server

All mutations go through `sync.create`, `sync.update`, and `sync.delete`. Each runs the
SQL write, then publishes:

```ts [server/api.ts]
import { touch } from "rxfy-server";
import { sync } from "./sync.js";

// edit — publishes a `patch`; every subscribed client applies it in place
await sync.update(postResource, postId, { title, body });

// create / delete — publish `stale` on the state channels that list this entity
await sync.create(postResource, { id: newId(), title, body }, { touch: [touch(postsChannel, {})] });
await sync.delete(postResource, postId, { touch: [touch(postsChannel, {})] });
```

## Fetch through the same endpoints

The client never imports server code — it calls your Hono routes through Hono's typed
`hc<AppType>` RPC client, the single source of truth for reads and writes. There's no wrapper to
write: `AppType` is just the type of your Hono app — export it once on the server, then
instantiate `hc<AppType>` wherever you mount the client:

:::code-group

```ts [src/api.ts (client)]
import { hc } from "hono/client";
import type { AppType } from "../server/api";

// Browser: a plain network trip to /api, fully typed from the routes above.
export const api = hc<AppType>("/api");
```

```ts [server/api.ts]
import { Hono } from "hono";

// Chaining the routes is what carries their input/output types to the client.
export const api = new Hono()
  .get("/posts", (c) => c.json({ posts: [] as Post[] }))
  .post("/posts", async (c) => c.json(await createPost(await c.req.json())));

// `AppType` is the type of the whole chained app — the only thing the client imports.
export type AppType = typeof api;
```

:::

During SSR you create the same client against Hono's in-process `app.request` instead of the
network, so fetches never leave the process. The server handler passes `app.request` into the
render as `apiFetch`, exactly the way it passes `live`; the entry builds the SSR client from it and
embeds sync grants with `sync.hydration(registry)` — the only change from the plain
[Add SSR](/getting-started/add-ssr) render, which used `dehydrate` / `hydrationScript`:

:::code-group

```tsx [src/entry-server.tsx]
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { hc } from "hono/client";
import type { Live } from "rxfy-server";
import { createModelRegistry } from "rxfy";
import { StoreProvider } from "rxfy-react";
import type { AppType } from "../server/api";
import { ApiProvider } from "./api-client.js";
import { App } from "./App.js";

// `live` is your createSync() object; `apiFetch` is Hono's in-process `app.request`.
export function render(url: string, live: Live, apiFetch: typeof fetch) {
  const registry = createModelRegistry();
  const api = hc<AppType>("http://ssr.internal", { fetch: apiFetch }); // in-process, no network hop

  return new Promise<{ html: string; state: string }>((resolve, reject) => {
    const { pipe } = renderToPipeableStream(
      <StoreProvider registry={registry} ssr>
        <ApiProvider client={api}>
          <App url={url} />
        </ApiProvider>
      </StoreProvider>,
      {
        onAllReady() {
          const sink = new PassThrough();
          let html = "";
          sink.on("data", (chunk: Buffer) => (html += chunk));
          // Same buffering as Add SSR — but embed signed sync grants, not a plain snapshot.
          sink.on("end", () => resolve({ html, state: sync.hydration(registry) }));
          pipe(sink);
        },
        onError: reject,
      },
    );
  });
}
```

```ts [server/index.ts]
// The catch-all HTML route wires `live` and the in-process `app.request` into the render.
app.get("*", async (c) => {
  const { html, state } = await render(c.req.path, live, app.request);
  return c.html(injectIntoTemplate(html, state)); // fills <!--app-html--> / <!--app-state-->
});
```

:::

Provide the client through `<ApiProvider>` and read it with `useApi()` — components call endpoints
directly, for reads and writes alike (`useStateData` intentionally ignores `fetchFn` identity, so
inline async arrows are fine):

```tsx [src/pages/PostsPage.tsx (abbreviated)]
import { useStateData } from "rxfy-react";
import { useApi } from "../api-client.js"; // the ApiProvider context from above
import { postsState } from "../states.js"; // your defineState from the Create Store level

const api = useApi();
const { data$, updatesAvailable$, applyUpdates } = useStateData({
  state: postsState,
  fetchFn: async () => (await api.posts.$get()).json(),
  params: {},
});
// writes call the endpoint directly
void api.posts.$post({ json: { title, body } }).then(() => applyUpdates());
```

## Add the Sync Client

Give `StoreProvider` a Sync Client built from the WebSocket transport. Your components stay
exactly as they were on the store path:

```tsx [src/entry-client.tsx]
import { createModelRegistry } from "rxfy";
import { createSyncClient, StoreProvider } from "rxfy-react";
import { createWsClient } from "rxfy-ws/client";
import { hc } from "hono/client";
import type { AppType } from "../server/api";
import { ApiProvider } from "./api-client.js";

const registry = createModelRegistry();
const apiClient = hc<AppType>("/api"); // browser client: plain network trip
const syncClient = createSyncClient({
  registry,
  transport: createWsClient({ url: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/live` }),
  renewUrl: "/api/live/renew",
});

hydrateRoot(
  document.getElementById("root")!,
  <StoreProvider registry={registry} ssr syncClient={syncClient}>
    <ApiProvider client={apiClient}>
      <App />
    </ApiProvider>
  </StoreProvider>,
);
```

There is no allow-list to read and nothing to pass by hand: the server signs a grant covering
exactly what it served, and the client lifts it from the payload automatically and subscribes
with it. SSR grants come from `sync.hydration(registry)`, embedded in the page and lifted on
hydration; client-only loads pick up the grant from each `sync.serve` response. Grants expire,
so the Sync Client renews them before expiry against the app-mounted `renewUrl` endpoint —
one route you add to your Hono app, behind your own auth, that reissues each grant with
`sync.renew`:

```ts [server/api.ts (renewal route)]
.post("/live/renew", async (c) => {
  const { grants } = await c.req.json<{ grants: string[] }>();
  return c.json({ grants: grants.map((g) => sync.renew(g)) });
})
```

See [Grants](/framework/server/grants).

## How it works

Edit a post in tab A. Tab B updates in place — no spinner, no reload, no code in the
component asking for it.

What happened, in order:

1. `sync.update` ran the update to database and got the new row.
2. It published a `patch` message on the entity's topic (`post:42`) into the hub.
3. The hub forwarded it to every socket subscribed to that entity topic — sockets that
   sent a valid grant covering it, not sessions the server tracked.
4. Each client wrote the entity into its shared `ModelStore` — the same write as
   `store.set` on the store path.
5. Every component subscribed to that id re-rendered. The query's id list was untouched,
   and nothing is re-fetched and nothing is re-rendered except the updated entity.

Edits travel as `patch`; creates and deletes instead mark the lists that reference the
entity as `stale`, surfacing a "click to refresh" badge. The two message kinds — and why
lists aren't updated in place — are covered in [Sync messages](/framework/server/messages).

## Click to refresh on `stale`

A `patch` merges itself — the entity is already in the store, so subscribed components just
re-render. A create or delete can't be merged blindly into a list you might be paginating or
sorting, so it arrives as `stale` instead: `useStateData` bumps the handle's `updatesAvailable$`
counter and leaves the current list untouched. You decide when to reload.

The idiomatic UI is a small badge that unwraps `updatesAvailable$` at the leaf with
[`Pending`](/react/pending) and calls `applyUpdates()` on click — which resets the counter and
re-fetches the list in place. It pulls both off its own `useStateData` handle for `postsState`;
the update counter is shared per state channel, so the badge stays self-contained with no props
to thread. Unwrapping late keeps the re-render scoped to the badge:

```tsx [src/pages/UpdatesBadge.tsx]
import { Pending, useStateData } from "rxfy-react";
import { useApi } from "../api-client.js"; // the ApiProvider context from earlier
import { postsState } from "../states.js"; // your defineState from the Create Store level

export function UpdatesBadge() {
  const api = useApi();
  const { updatesAvailable$, applyUpdates } = useStateData({
    state: postsState,
    fetchFn: async () => (await api.posts.$get()).json(),
    params: {},
  });

  return (
    <Pending value$={updatesAvailable$} getDefaultValue={() => 0}>
      {(n) =>
        n > 0 ? (
          <button onClick={applyUpdates}>
            {n} new post{n === 1 ? "" : "s"} · refresh
          </button>
        ) : null
      }
    </Pending>
  );
}
```

Drop `<UpdatesBadge />` anywhere above your list — no props to thread. With no Sync Client in
context, `updatesAvailable$` simply stays at `0` and the badge renders nothing, so the same
component works with or without the sync stack. See
[`updatesAvailable$` / `applyUpdates`](/react/sync-client) for the full handle API.

## Next steps

Go deeper on the ideas underneath everything you just wired:

* [Observables](/core-concepts/observables) — the value-over-time mental model behind `Atom` and
  `data$` that everything downstream subscribes to.
* [Normalization](/core-concepts/normalization) — why `data$` emits ids, and how one store keeps
  every view of an entity in sync.
* [Late Unwrapping](/core-concepts/late-unwrapping) — keep values wrapped until the leaf, so a
  write re-renders only what changed.
* [Server-Side Rendering](/core-concepts/ssr) — `dehydrate` / `hydrate` in full, plus streaming
  and two-pass modes.

Or see it running end to end: the [Examples](/examples) index collects runnable apps from
client-only all the way to fully synced.

:::tip
Not ready for a server? Everything above sits on the store concepts — the
[Create Store](/getting-started/create-store) covers them in one file, and
[Server-Side Rendering](/core-concepts/ssr) adds SSR without the sync stack.
:::
