Guides
Getting started
Build a React counter with Zustand and Yjs, run a local sync server, and see changes arrive in a second browser tab.
Build a shared counter with React, Zustand and Yjs. By the end, clicking Increment in one browser tab updates the other, and a newly opened tab adopts the room's current count.
You need Node.js 24 or newer, npm, and two terminals. Homeostate keeps your store in sync with a CRDT document; a Yjs WebSocket provider carries that document's updates between peers.
Create an app
Create a React + TypeScript app with Vite. If prompted to install and start it immediately, choose No, then run the remaining commands:
npm create vite@latest homeostate-counter -- --template react-ts
cd homeostate-counter
npm installIf you already have a React + TypeScript app, start with the install step below.
Install
Install the sync engine, the Zustand adapter, the Yjs backend and their dependencies:
npm install @homeostate/core @homeostate/store-zustand zustand@^5 @homeostate/crdt-yjs yjs@^13.6.0 y-websocket@^2.1.0This guide uses y-websocket 2.x, which includes the local server command used below.
Homeostate packages are in 0.x, so breaking changes may come in minor versions.
Start the sync server
In the first terminal, from the app's directory, run:
npx y-websocketLeave it running. It listens on ws://localhost:1234 by default. The provider and server
are supplied by y-websocket; Homeostate
handles the synchronization between the store and the document.
Sync a store
Create src/counter.ts:
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { create } from "zustand";
import { createYjsBackend } from "@homeostate/crdt-yjs";
import { homeostate } from "@homeostate/store-zustand";
type CounterState = { count: number; increment: () => void };
export const doc = new Y.Doc();
export const useCounter = create<CounterState>()(
homeostate(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ backend: createYjsBackend(doc, "shared"), connect: "manual" },
),
);
export const provider = new WebsocketProvider(
"ws://localhost:1234",
"homeostate-counter",
doc,
);
provider.once("sync", () => useCounter.homeostate.connect());
if (import.meta.hot) {
import.meta.hot.dispose(() => {
useCounter.homeostate.disconnect();
provider.destroy();
doc.destroy();
});
}The homeostate middleware syncs the store with doc.getMap("shared"). The default
filter excludes the increment function, so only count is shared.
connect: "manual" creates the store without touching the document, and the
provider.once("sync", ...) line connects it once the provider has received the room's
state. A joining tab then adopts the count already in the room instead of writing its initial
zero over it, and the first tab in an empty room writes its count into it. Clicks before then
change only this tab, and the room's count replaces them when it connects.
Every tab uses the same server URL, room name (homeostate-counter) and map name (shared).
The final block cleans up the old store and connection when Vite reloads this module during
editing.
Add the counter UI
Replace src/App.tsx with the following. Keep Vite's generated src/main.tsx; it already
renders App.
import { useEffect, useState } from "react";
import { provider, useCounter } from "./counter";
export default function App() {
const count = useCounter((state) => state.count);
const increment = useCounter((state) => state.increment);
const [synced, setSynced] = useState(provider.synced);
useEffect(() => {
provider.on("sync", setSynced);
setSynced(provider.synced);
return () => provider.off("sync", setSynced);
}, []);
return (
<main>
<h1>Shared counter</h1>
<p role="status">
{synced ? "Connected" : "Connecting to the sync server…"}
</p>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</main>
);
}Try two tabs
In a second terminal, from the same app directory, start Vite:
npm run dev- Open the URL Vite prints, usually
http://localhost:5173, in two browser tabs. - Wait until both show Connected. Click Increment in the first tab: both counts
should become
1. - Click Increment in the second tab: both counts should become
2. - Refresh one tab, or open a third: it should show
2after connecting, without resetting the other tabs.
Use a different browser or a private window as an additional check: Yjs also exchanges updates directly between tabs in the same browser, so a cross-browser check verifies the WebSocket server is carrying the changes.
If the status stays on Connecting, check that the sync server is still running on port
1234. If Vite chooses a different port, open the exact same Vite URL in both tabs. If port
1234 is occupied, stop the other local server before starting this one.
This example shares a numeric value. Simultaneous clicks can resolve to the same count; it is not an additive CRDT counter that preserves every concurrent increment.
Use another store or backend
Homeostate has three pieces. Swap the Zustand adapter or Yjs backend for the packages your app uses:
@homeostate/core, the sync engine.- One store adapter for your state manager: Zustand, Redux, MobX, MobX-State-Tree, Jotai, Valtio, TanStack Store or NgRx Signals.
- One CRDT backend for your replication library: Yjs, Loro or Automerge.
Every other store adapter uses createSyncEngine from core instead of the Zustand
middleware; each package's introduction shows its setup.
Next steps
- Try the React playground
for todo and text-editing examples. From a checkout of this repository, run
pnpm installandpnpm playground:reactto start it with its sync server. - Concepts explains the engine and how
connect()reconciles a store with an existing document. @homeostate/corecovers the engine options.- Persistence keeps the document in the browser so it survives reloads even when the server and all other peers are unavailable. This counter has no persistence.