SyncroState brings Svelte 5 reactivity to the multiplayer level. Built on top of Yjs, it provides a reactive and type-safe way to build multiplayer experiences.
Inspired by Syncedstore, SyncroState modernizes collaborative state management by leveraging new Svelte 5's reactivity system. It provides a natural way to work with synchronized state that feels just like a regular Svelte $state.
โ ๏ธ The demo uses a public Liveblocks API key which may become rate-limited. I recommend using your own API key for thorough testing.
- ๐ Powered by Yjs - Industry-leading CRDT for conflict-free real-time collaboration
- ๐ Type-Safe - Full TypeScript support with rich type inference and schema validation
- ๐ซ Svelte DX - Works like regular Svelte state with fine-grained reactivity and simple mutations
- ๐ฏ Rich Data Types - Support for primitives, arrays, objects, dates, enums, and sets.
- ๐ Provider Agnostic - Works with Liveblocks, PartyKit, or any Yjs provider
- ๐ Local Persistence ready - Support for y-indexeddb for offline use
- โฉ๏ธ Undo/Redo - Built-in support for state history
- ๐ฎ Bindable - Use
bind:value
like you would with any Svelte state - ๐จ Optional & Nullable - Flexible schema definition with optional and nullable fields
# Using pnpm
pnpm add syncrostate
# Using bun
bun add syncrostate
# Using npm
npm install syncrostate
SyncroState uses a schema-first approach to define your collaborative state. Here's a simple example:
The schema defines both the structure and the types of your state. Every field is automatically:
- Type-safe with TypeScript
- Reactive with Svelte
- Synchronized across clients
- Validated against the schema
Once created, you can use the state like a regular Svelte state: mutate it, bind it, use mutative methods, etc.
<script>
import { syncroState, y } from 'syncrostate';
import { LiveblocksYjsProvider } from '@liveblocks/yjs';
import { createClient } from '@liveblocks/client';
const document = syncroState({
// Optional but required for remote sync: Connect and sync to a yjs provider
// If omitted, state will be local-only and in memory.
sync: ({ doc, synced }) => {
const client = createClient({
publicApiKey: 'your-api-key'
});
const { room } = client.enterRoom('room-id');
const provider = new LiveblocksYjsProvider(room, doc);
provider.on('synced', () => {
synced();
});
},
// Define your state schema. It must be an object
schema: {
// Primitive values
name: y.string(),
age: y.number(),
isOnline: y.boolean(),
lastSeen: y.date(),
theme: y.enum(['light', 'dark']),
// Nested objects
preferences: y.object({
theme: y.enum(['light', 'dark']),
notifications: y.boolean()
}),
// Arrays of any type
todos: y.array(y.object({
title: y.string(),
completed: y.boolean()
})),
// Sets of any primitive type
colors: y.set(y.string())
}
});
</script>
<!-- Use it like regular Svelte state -->
<input bind:value={document.name} />
<button onclick={() => document.todos.push({ title: 'New todo', completed: false })}>
Add Todo
</button>
SyncroState combines the power of Svelte's reactivity system with Yjs's CRDT capabilities to create a seamless real-time collaborative state management solution. Here's how it works under the hood:
-
Proxy-based State Tree: When you create a state using
syncroState()
, it builds a tree of proxy objects that mirror your schema structure. Each property (primitive or nested) is wrapped in a specialized proxy that leverages Svelte's reactivity through$state
or specialized proxy likeSvelteDate
orSvelteSet
and soonSvelteMap
. -
Mutation Trapping: These proxies intercept all state mutations (assignments, mutative operations, object modifications, reassignments). This allows SyncroState to:
- Validate changes against the schema
- Update the local Svelte state immediately for responsive UI updates
- Forward changes to the underlying Yjs document
-
Yjs Integration: The state is backed by Yjs types in the following way:
- Primitive values (numbers, booleans, dates, enums) are stored using Y.Text
- Arrays are stored using Y.Array
- Objects are stored using Y.Map
When you modify the state:
- The change is wrapped in a Yjs transaction
- For primitives, the value is serialized and stored in the Y.Text
- For collections, the corresponding Y.Array or Y.Map is updated.
- Yjs handles conflict resolution and ensures eventual consistency
-
Remote Updates: When changes come in from other clients:
- Yjs observer callbacks are triggered
- The proxies update their internal Svelte state
- Svelte's reactivity system automatically updates any UI components using that state
- The schema you define isn't just for TypeScript types - it creates specialized proxies that understand how to handle each type of data
- Nested objects and arrays create nested proxy structures that maintain reactivity at every level
- All mutations are validated against the schema before being applied
This architecture ensures that:
- Local changes feel instant and responsive
- All clients converge to the same state
- The state remains type-safe and valid
- You get Svelte's fine-grained reactivity for optimal performance
To add a persistence provider like y-indexeddb and use a remote provider like Liveblocks or y-websocket you will do something like this:
import { IndexeddbPersistence } from "y-indexeddb";
import { createClient } from "@liveblocks/client";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
const document = syncroState({
sync: ({ doc, synced }) => {
const docName = "your-doc-name";
const localProvider = new IndexeddbPersistence(docName, doc);
const remoteClient = createClient({
publicApiKey: "your-api-key"
});
const { room } = remoteClient.enterRoom(docName);
localProvider.on("synced", () => {
const remoteProvider = new LiveblocksYjsProvider(room, doc);
remoteProvider.on("synced", () => {
synced();
});
});
}
// ... your schema
});
When you are using a remote provider, you might want to wait for the state to be synced before doing something.
The syncrostate object has a getState()
methods that return the state of the syncronisation from which you can get the synced
property to check if the state is synced.
{#if document.getState?.().synced}
<div>My name is {document.name}</div>
{/if}
If you want to edit multiple object properties at once it's preferable to reassign the entire object. This way, syncrostate can apply the changes inside a single transaction and avoid partial updates. Only the properties that are being changed will trigger reactivity and remote updates.
// Instead of this
state.user.name = "John";
state.user.age = 30;
// Do this
state.user = {
...state.user,
name: "John",
age: 30
};
Every syncrostate object or array has three additional methods: getState
, getYTypes
and getYTypes
.
getState
returns the statetype State
of the syncronisation.getYTypes
returns the underlying YObject or YArray.getYTypes
returns the YJS types children of the YObject or YArray.
type State {
synced: boolean;
awareness: Awareness;
doc: Y.Doc;
undoManager: Y.UndoManager;
transaction: (fn: () => void) => void;
transactionKey: any;
undo: () => void;
redo: () => void;
}
SyncroState uses Yjs's undo/redo system to provide undo/redo functionality. These methods are available through the getState
method.
- Add support for Set and Map types
- Find a way to make syncrostate schema optional
- Add support for recursive types
- Add support for nested documents
- Add a simple way to manage awareness sharing
SyncroState is licensed under the MIT License. See the LICENSE file for details.