npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

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.
  • undefined is 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 y instance or a yAtom source atom.
  • Supports nullable sources: y/yAtom can be null (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 yjs

Quick 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 resubscribeOnSourceChange and 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 latest yAtom value.
  • 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:

  • read will receive null and can return a placeholder (e.g. null or a default object).
  • When the source is null the 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 null to a real instance, the atom subscribes and syncs the snapshot according to resubscribeOnSourceChange:
    • Default false: the first non-null instance is pinned; later yAtom changes do not switch the active source.
    • true: each source change causes an unsubscribe/subscribe and an immediate fallback to read(y).

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: equals prevents redundant updates; defaults to deep equality.
  • Deep vs shallow observation:
    • deep: true uses observeDeep and ignores eventFilter.
    • deep: false (default) uses narrow observation; you can provide eventFilter to filter events precisely.
  • Nullable sources: 当 y/yAtomnull 时不订阅,read 仍会执行;写入会被忽略并在 dev 环境告警;出现真实实例后按 resubscribeOnSourceChange 语义运行(默认 pinned)。 Nullable sources: when y/yAtom is null, the atom does not subscribe to Y events but read still runs; writes are no‑ops (with a dev warning). When a real instance becomes available, the atom follows the resubscribeOnSourceChange semantics (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 via withTransact.
  • Transactions carry origins: writes from createYAtom and createYPathAtom are tagged with default origins ([y-jotai] atom-write / [y-jotai] path-write); you can override this via transactionOrigin for easier debugging.
  • Unmount cleanup: subscriptions are removed on unmount; no callbacks after unsubscribe.

Patterns

  • Start coarse and refine: begin with a single createYAtom per document; split into smaller atoms only when profiling indicates a need.

  • Opt for factories when focusing:

    • createYMapKeyAtom(map, key) for single key
    • createYMapEntryAtom(map, key, { deleteOnNull }) for Y type reference stored at a key (narrow to replacements); set deleteOnNull: true to delete key when writing null
    • createYMapFieldsAtom(map, ['title', 'status'], { deleteOnUndefined }) for partial projections of a Map; only writes changed fields; set deleteOnUndefined: true to delete keys when writing undefined
    • createYArrayIndexAtom(array, index) for single item
    • createYTextAtom(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 (null vs undefined) to avoid conflicting behaviors.

  • Arbitrary paths: createYPathAtom(root, ['a', 0, 'b']) traverses Map/Array mixes.

    • Default writer semantics:
      • Map: undefined is ignored; use a custom writer or dedicated delete atom when you need to remove keys.
      • Array: index is clamped to [0, length]; undefined is ignored; use a custom writer or dedicated delete atom when you need to remove slots.

Notes on resubscribeOnSourceChange

  • Default is false (stable/pinned):
    • Subscriptions stay on the initial instance even if yAtom later 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.
  • true (follow source):
    • On yAtom change, unsubscribe old, subscribe new, and sync immediately.
    • Reads/writes both target the latest yAtom instance (safe for doc swap flows).
    • Use when you intentionally swap documents/roots and want updates to follow.

Advanced Usage

Lifecycle: init vs sync

Internally the subscription has two actions to keep state predictable:

  • init (on mount)
    • If resubscribeOnSourceChange is false, capture the initial y and keep using it even if yAtom later 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.
  • sync (on Y events)
    • Refresh the cached snapshot using read(y); suppressed by equals to avoid redundant updates.

Timeline when resubscribeOnSourceChange: true and yAtom changes:

  • The effect unsubscribes from the old y and subscribes to the new one.
  • Reads immediately reflect the new y via a direct read(y) fallback (bypassing the previous cached snapshot).
  • The next Y event (or batch inside a transaction) runs sync and 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