Skip to content
Homeostate

Zustand

@homeostate/store-zustand

Keep a Zustand store in sync with a Yjs, Loro or Automerge document, with the homeostate middleware or an adapter for an existing store.

@homeostate/store-zustand@0.3.0npmSource

Zustand store adapter and middleware for @homeostate/core. It keeps a Zustand store in sync with a CRDT backend such as Yjs, Loro or Automerge.

Install

npm install @homeostate/core @homeostate/store-zustand zustand @homeostate/crdt-yjs yjs

zustand 4.5 or 5 is a peer dependency.

Usage

Wrap the state creator with the homeostate middleware. The store connects as soon as it is created, or once persist has hydrated it, and the engine is exposed as store.homeostate:

import * as Y from "yjs";
import { create } from "zustand";
import { createYjsBackend } from "@homeostate/crdt-yjs";
import { homeostate } from "@homeostate/store-zustand";

type CounterState = { count: number; increment: () => void };

const doc = new Y.Doc();
const useCounter = create<CounterState>()(
  homeostate(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { backend: createYjsBackend(doc, "shared") },
  ),
);

useCounter.homeostate.disconnect(); // stop syncing

The second argument holds the options, as in Zustand's own middlewares:

OptionDefaultDescription
backendThe CrdtBackend the store syncs with
connect"auto""manual" leaves store.homeostate.connect() to the app; see below
omit, keys, filter, seed, schedule, onErrorThe engine's SyncEngineConfig, typed by the store's state

omit and keys take the state's key names, so { backend, omit: ["draft"] } keeps a draft on each device, and a misspelled key fails to compile.

Connecting later

With connect: "manual", the store is created without touching the backend, and the app connects it when it is ready, for example once its provider has received the room's state:

const useCounter = create<CounterState>()(
  homeostate(creator, {
    backend: createYjsBackend(doc, "shared"),
    connect: "manual",
  }),
);

provider.once("sync", () => useCounter.homeostate.connect());

Until then the store works locally. Connecting then works as on any connect(): the room's values replace the store's for every key the room holds, edits made meanwhile included, and keys the room lacks seed it.

To sync a store you already have, create the engine yourself with createZustandAdapter:

import { createSyncEngine } from "@homeostate/core";
import { createZustandAdapter } from "@homeostate/store-zustand";

const engine = createSyncEngine(
  createYjsBackend(doc, "shared"),
  createZustandAdapter(useCounter),
);
engine.connect();

Only data is synced: the default filter skips actions and other functions in the state, and remote changes leave them in place.

Combining with other middlewares

Put persist inside homeostate:

import { createJSONStorage, persist } from "zustand/middleware";

const useCounter = create<CounterState>()(
  homeostate(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
      }),
      { name: "counter", storage: createJSONStorage(() => localStorage) },
    ),
    { backend: createYjsBackend(doc, "shared") },
  ),
);
MiddlewareOrderNotes
persistInside: homeostate(persist(...), options)Outside, creating the store throws
devtoolsEither; outermost, as Zustand recommendsRemote changes show up as anonymous actions
immerEither
subscribeWithSelectorEither

All four together:

devtools(
  subscribeWithSelector(
    homeostate(persist(immer(creator), persistOptions), { backend }),
  ),
);

persist outside homeostate hydrates the store only after the middleware has connected it, so the creator's state would overwrite storage, or the persisted state the room. The middleware detects that order and throws when the store is created, naming the right one.

With persist inside, the store connects once it is hydrated: at once with synchronous storage such as localStorage, and after the read with asynchronous storage such as React Native's AsyncStorage or an IndexedDB wrapper. Until then store.homeostate.isConnected() is false. Calling store.homeostate.disconnect() cancels the pending connect, and calling store.homeostate.connect(), as with connect: "manual", connects once hydration has finished. If hydration fails, the store stays disconnected, and Zustand passes the error to the callback returned by onRehydrateStorage; store.persist.rehydrate() retries, and the store connects once it succeeds.

Connecting then works as on any connect(): the room wins over the persisted state, and persisted keys the room lacks seed it. So edits persisted while offline are lost when the room already has state; keeping them across a reconnect is not supported yet. For offline apps, persist the CRDT document with createPersistence instead of the store: the document records offline edits and merges them with the room's.