y-jotai
v0.4.0
Published
Thin, typed bridge between Yjs types and Jotai atoms.
Readme
y-jotai (Jotai + Yjs)
Thin, typed bridge between Yjs types and Jotai atoms.
[!WARNING] This is an early release. The API and behavior may change in future versions. This library is small and opinionated; please read the Semantics before adopting.
Highlights
- Semantics first: reads are pure projections; writes are explicit.
undefinedis ignored by default (no implicit delete); delete explicitly or provide a custom writer.- All writes run in Y transactions and can carry an origin for observability.
- Accepts both a concrete
yinstance or ayAtomsource atom. - Supports nullable sources:
y/yAtomcan benull(no subscription; read still runs; writes no-op with dev warning until ready). - Event-driven updates from Yjs; writes rely on Y events to refresh the snapshot (no manual state sets).
- Narrow subscriptions by default; opt-in deep observation when you need it.
- SSR/hydration-friendly: first frame matches the current Y state.
Installation
npm install y-jotai jotai yjsQuick Start
import * as Y from 'yjs'
import { Provider, useAtom } from 'jotai'
import { createYAtom } from 'y-jotai'
const doc = new Y.Doc()
const map = doc.getMap<string>('root')
// Treat the whole Y.Map as a single Jotai atom.
const snapshotAtom = createYAtom({
y: map,
read: (m) => m.toJSON() as Record<string, string>,
})
function Example() {
const [snapshot, setSnapshot] = useAtom(snapshotAtom)
const onRename = () => setSnapshot((prev) => ({ ...prev, title: 'Hello peers' }))
return (
<>
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
<button onClick={onRename}>Rename</button>
</>
)
}
export const App = () => (
<Provider>
<Example />
</Provider>
)Using a yAtom Source (atomFamily-friendly)
[!WARNING] Prior to using this pattern, ensure you understand the semantics of
resubscribeOnSourceChangeand that your state management design is sound.
When your Y instance itself comes from a Jotai atom (e.g., atomFamily(id) returning a Y.Map), pass it via yAtom.
import { atomFamily } from 'jotai/utils'
// Each document exposes its own root map via an atom.
const rootMapFamily = atomFamily((id: string) => atom(docFor(id).getMap('root')))
export const titleAtomFamily = atomFamily((id: string) =>
createYAtom({
yAtom: rootMapFamily(id),
read: (m) => m.get('title') ?? '',
write: (m, next) => m.set('title', next),
eventFilter: (evt) => (evt.keysChanged ? evt.keysChanged.has('title') : true),
// Optional: switch subscriptions when the source instance changes.
// resubscribeOnSourceChange: true,
})
)By default, subscriptions are pinned to the initial instance (resubscribeOnSourceChange: false). Set it to true to automatically unsubscribe from the old Y instance and subscribe to the new one, with an immediate snapshot sync.
Notes
- Writes follow the active Y instance:
resubscribeOnSourceChange: false(default): reads/writes stay pinned to the initial instance from first mount.resubscribeOnSourceChange: true: reads/writes move with the latestyAtomvalue.
- Updates always flow via Y events; no manual state set after writes.
- SSR/hydration: derived state ensures the first frame matches the current Y snapshot.
Nullable source (writing when the root isn't ready)
In real apps the Y root is often null initially and later swapped for a real Y type when ready. createYAtom and createYMapEntryAtom now accept y/yAtom being null:
readwill receivenulland can return a placeholder (e.g.nullor a default object).- When the source is
nullthe atom does not subscribe to Y events. - Writes are no-ops while the source is
null; a dev warning is emitted to avoid writing to an uninitialized document. - When the source changes from
nullto a real instance, the atom subscribes and syncs the snapshot according toresubscribeOnSourceChange:- Default
false: the first non-null instance is pinned; lateryAtomchanges do not switch the active source. true: each source change causes an unsubscribe/subscribe and an immediate fallback toread(y).
- Default
Example: root not ready → ready flow
const rootRefAtom = atom<Y.Map<unknown> | null>(null)
// Nullable map source
const cellMapRefAtom = atom((get) => {
const root = get(rootRefAtom)
return root ? (root.get('cells') as Y.Map<unknown>) : null
})
// Entry atom also accepts a mapAtom that may be null
const cellEntryFamily = atomFamily((id: string) =>
createYMapEntryAtom<Y.Map<any>>(cellMapRefAtom, id, {
deleteOnNull: true,
resubscribeOnSourceChange: true,
})
)
// On read, read receives null → produce a placeholder
const cellTitleFamily = atomFamily((id: string) =>
createYAtom({
yAtom: cellEntryFamily(id),
read: (cell) => (cell ? (cell.get('title') as string | null) : null),
write: (cell, next) => cell.set('title', next),
resubscribeOnSourceChange: true,
})
)Behavior & Semantics
- Event-driven updates: writes rely on Y events to update the cached snapshot (no direct state set after write).
- Equality suppression:
equalsprevents redundant updates; defaults to deep equality. - Deep vs shallow observation:
deep: trueusesobserveDeepand ignoreseventFilter.deep: false(default) uses narrow observation; you can provideeventFilterto filter events precisely.
- Nullable sources: 当
y/yAtom为null时不订阅,read仍会执行;写入会被忽略并在 dev 环境告警;出现真实实例后按resubscribeOnSourceChange语义运行(默认 pinned)。 Nullable sources: wheny/yAtomisnull, the atom does not subscribe to Y events butreadstill runs; writes are no‑ops (with a dev warning). When a real instance becomes available, the atom follows theresubscribeOnSourceChangesemantics (pinned/false by default). - Transactions coalesce: multiple Y operations inside a single
doc.transact(...)result in at most one update. - Writer supports functional updates:
set(atom, prev => next)is supported; the write executes inside a transaction viawithTransact. - Transactions carry origins: writes from
createYAtomandcreateYPathAtomare tagged with default origins ([y-jotai] atom-write/[y-jotai] path-write); you can override this viatransactionOriginfor easier debugging. - Unmount cleanup: subscriptions are removed on unmount; no callbacks after unsubscribe.
Patterns
Start coarse and refine: begin with a single
createYAtomper document; split into smaller atoms only when profiling indicates a need.Opt for factories when focusing:
createYMapKeyAtom(map, key)for single keycreateYMapEntryAtom(map, key, { deleteOnNull })for Y type reference stored at a key (narrow to replacements); setdeleteOnNull: trueto delete key when writingnullcreateYMapFieldsAtom(map, ['title', 'status'], { deleteOnUndefined })for partial projections of a Map; only writes changed fields; setdeleteOnUndefined: trueto delete keys when writingundefinedcreateYArrayIndexAtom(array, index)for single itemcreateYTextAtom(text)for text content
[!IMPORTANT] deleteOnNull and deleteOnUndefined are mutually exclusive. Enable only the option that matches the sentinel value you want to treat as a deletion marker (
nullvsundefined) to avoid conflicting behaviors.Arbitrary paths:
createYPathAtom(root, ['a', 0, 'b'])traverses Map/Array mixes.- Default writer semantics:
- Map:
undefinedis ignored; use a custom writer or dedicated delete atom when you need to remove keys. - Array: index is clamped to [0, length];
undefinedis ignored; use a custom writer or dedicated delete atom when you need to remove slots.
- Map:
- Default writer semantics:
Notes on resubscribeOnSourceChange
- Default is
false(stable/pinned):- Subscriptions stay on the initial instance even if
yAtomlater changes. - Reads/writes both hit the initial instance (avoids ghost writes to a different doc).
- Use when you want stability and the source is expected to remain the same.
- Subscriptions stay on the initial instance even if
true(follow source):- On
yAtomchange, unsubscribe old, subscribe new, and sync immediately. - Reads/writes both target the latest
yAtominstance (safe for doc swap flows). - Use when you intentionally swap documents/roots and want updates to follow.
- On
Advanced Usage
Lifecycle: init vs sync
Internally the subscription has two actions to keep state predictable:
init(on mount)- If
resubscribeOnSourceChangeisfalse, capture the initialyand keep using it even ifyAtomlater returns a new instance. - If the source is dynamic (
yAtom), seed the first snapshot so the initial read is consistent before any Y events fire.
- If
sync(on Y events)- Refresh the cached snapshot using
read(y); suppressed byequalsto avoid redundant updates.
- Refresh the cached snapshot using
Timeline when resubscribeOnSourceChange: true and yAtom changes:
- The effect unsubscribes from the old
yand subscribes to the new one. - Reads immediately reflect the new
yvia a directread(y)fallback (bypassing the previous cached snapshot). - The next Y event (or batch inside a transaction) runs
syncand caches the fresh snapshot.
This design ensures predictable SSR/first frame and safe transitions when swapping documents or roots at runtime.
Resubscribe is enabled and yAtom swaps from old to new. Reads fall back to read(y) immediately; cache updates on the next Y event.
Component Store subscriptionAtom Y(old) Y(new)
| | | | |
| read | | | |
|----------->| | | |
| | init | | |
| |------------------->| | |
| | | subscribe | |
| | |------------------>| observe |
| | | seed snapshot | |
| |<-------------------| | |
| snapshot | | | |
| | | | |
-- swap doc: yAtom -> Y(new) -----------------------------------------------
| | rerun effect | | |
| | | unsubscribe | |
| | |------------------>| unobserve |
| | | subscribe |
| | |----------------------------------->| observe
| read | | |
|----------->| | |
| | lastY != activeY | |
| | return read(Ynew) | |
|<-----------| | |
| | | <event> sync |
| | |<------------------------------------|
| | | snapshot <- read(Ynew) |
| |<--------------------| |Example: swapping documents safely
const currentDocAtom = atom<Y.Doc>(() => new Y.Doc())
const rootMapAtom = atom((get) => get(currentDocAtom).getMap('root'))
export const titleAtom = createYAtom({
yAtom: rootMapAtom,
read: (m) => m.get('title') ?? '',
write: (m, next) => m.set('title', next),
eventFilter: (evt) => (evt.keysChanged ? evt.keysChanged.has('title') : true),
resubscribeOnSourceChange: true,
})
// Later, swap the document
set(currentDocAtom, new Y.Doc())
// Reads from titleAtom immediately use the new map's value; the cache updates on the next Y event.Recipes
These snippets are minimal, copy-pastable starting points for common cases.
Map key atom (typed, with decode/encode)
import * as Y from 'yjs'
import { createYMapKeyAtom } from 'y-jotai'
const doc = new Y.Doc()
const settings = doc.getMap<unknown>('settings')
// Treat missing as false, and coerce non-boolean inputs.
export const darkModeAtom = createYMapKeyAtom<unknown, boolean>(settings, 'darkMode', {
decode: (v) => Boolean(v ?? false),
encode: (v) => Boolean(v),
})Map entry atom (Y types stored inside a Map)
import * as Y from 'yjs'
import { createYMapEntryAtom } from 'y-jotai'
const doc = new Y.Doc()
const blocks = doc.getMap<Y.Map<any> | null>('blocks')
// Subscribe to a nested Y.Map by key; updates when the reference is replaced.
// With deleteOnNull: true, writing null removes the key entirely (no tombstone).
export const blockMapAtom = createYMapEntryAtom<Y.Map<any>>(blocks, 'activeBlock', {
deleteOnNull: true, // writing null will delete the key instead of storing null
})Map fields atom (partial projection of a Map)
import * as Y from 'yjs'
import { createYMapFieldsAtom } from 'y-jotai'
const doc = new Y.Doc()
const metadata = doc.getMap<string | number>('metadata')
type Meta = { title?: string; count?: number }
// Keys infer from the const tuple; only writes fields that actually changed.
// With deleteOnUndefined: true, writing undefined removes that key.
export const metaFieldsAtom = createYMapFieldsAtom<Meta>(
metadata,
['title', 'count'] as const,
{
includeUndefined: true, // include missing fields as undefined in read
deleteOnUndefined: true, // writing undefined deletes the key
}
)
// Only 'title' will be written to CRDT (count unchanged, no redundant ops)
// set(metaFieldsAtom, prev => ({ ...prev, title: 'New Title' }))
// Delete 'title' key from the map
// set(metaFieldsAtom, prev => ({ ...prev, title: undefined }))Array index atom (replace item in place)
import * as Y from 'yjs'
import { createYArrayIndexAtom } from 'y-jotai'
type Todo = { id: string; title: string; done: boolean }
const doc = new Y.Doc()
const todos = doc.getArray<Todo>('todos')
export const firstTodoAtom = createYArrayIndexAtom<Todo, Todo | undefined>(todos, 0)
// set(firstTodoAtom, (t) => t ? { ...t, done: true } : t)Text atom (diff-based writer)
import * as Y from 'yjs'
import { createYTextAtom } from 'y-jotai'
const doc = new Y.Doc()
const ytext = doc.getText('content')
export const textAtom = createYTextAtom(ytext)
// set(textAtom, (s) => s + "!")Path atom (Map/Array traversal with default writer)
import * as Y from 'yjs'
import { createYPathAtom } from 'y-jotai'
const doc = new Y.Doc()
const root = doc.getMap('root')
// Access root.profile.friends[0].name
export const firstFriendNameAtom = createYPathAtom<string | undefined>(root, [
'profile', 'friends', 0, 'name',
])
// Default writer ignores undefined; to delete a key,
// provide a custom writer or a dedicated delete atom.Non-goals / Safety
- No automatic diff/patch for arbitrary objects (beyond the built-in text diff); bring your own if needed.
- No implicit deletes: default writers ignore
undefined; delete explicitly or supply a custom writer. - Yjs values should be JSON-like/serializable; avoid storing non-serializable data if you need portability or persistence.
From coarse to fine (eventFilter for precision)
import * as Y from 'yjs'
import { createYAtom } from 'y-jotai'
const doc = new Y.Doc()
const map = doc.getMap<any>('root')
// Start coarse: one atom for the whole map
export const snapshotAtom = createYAtom({
y: map,
read: (m) => m.toJSON() as Record<string, unknown>,
})
// Later, split out a focused title atom with a precise eventFilter
export const titleAtom = createYAtom({
y: map,
read: (m) => (m.get('title') as string | undefined) ?? '',
write: (m, next) => m.set('title', next),
eventFilter: (evt) => (evt.keysChanged ? evt.keysChanged.has('title') : true),
})Why another bridge?
- You keep the Jotai mental model while syncing collaborative state through Yjs.
- Flexible granularity: from whole-doc snapshots to focused keys/indices.
- No extra state fan-out: Y events are the single source of truth.
FAQ
- Do I need fine-grained atoms? Not necessarily. Start with a single atom per document and refine when needed.
- Can I mix with local Jotai atoms? Yes. They share the same store and compose naturally.
- How about Valtio? Choose the state library you prefer. This package focuses on Jotai idioms and narrow subscriptions.
License
MIT © Wibus
