Framework-agnostic hooks for building real-time collaborative applications with Loro CRDT documents. This package provides the core hook implementations that are used by @loro-extended/react and @loro-extended/hono.
This package implements a "Doc-first" pattern for working with Loro documents:
useDocument- Get a typed document from the repo (stable reference)useValue- Subscribe to document or ref changesusePlaceholder- Get placeholder values from schema definitionsuseLens- Create a Lens and subscribe to worldview snapshotsuseEphemeral- Subscribe to ephemeral store changes (presence, cursors, etc.)useDocIdFromHash- Sync document ID with URL hash for shareable links
This package is typically not installed directly. Instead, use one of the framework-specific packages:
@loro-extended/react- For React applications@loro-extended/hono- For Hono/JSX applications
If you're building a custom framework integration:
npm install @loro-extended/hooks-core @loro-extended/repo @loro-extended/changeCreates framework-specific hooks from a framework hooks object.
import { createHooks } from "@loro-extended/hooks-core"
import * as React from "react"
export const {
RepoContext,
useRepo,
useDocument,
useLens,
useEphemeral,
useDocIdFromHash,
} = createHooks(React)The framework object must implement these hooks:
interface FrameworkHooks {
useState: <T>(initialState: T | (() => T)) => [T, (newState: T | ((prevState: T) => T)) => void]
useEffect: (effect: () => undefined | (() => void), deps?: unknown[]) => void
useCallback: <T extends Function>(callback: T, deps: unknown[]) => T
useMemo: <T>(factory: () => T, deps: unknown[]) => T
useRef: <T>(initialValue: T) => { current: T | null }
useSyncExternalStore: <Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
) => Snapshot
useContext: <T>(context: any) => T
createContext: <T>(defaultValue: T) => any
}A context for providing the Repo instance to child components.
<RepoContext.Provider value={repo}>
{children}
</RepoContext.Provider>Returns the Repo instance from context.
const repo = useRepo()Returns a typed Doc for the given document. The document reference is stable and never changes, preventing unnecessary re-renders.
// Without ephemeral stores
const doc = useDocument(docId, docSchema)
// With ephemeral stores (e.g., presence)
const doc = useDocument(docId, docSchema, { presence: PresenceSchema })Parameters:
docId: DocId- The document identifierdocSchema: DocShape- The document schema (from@loro-extended/change)ephemeralShapes?: EphemeralDeclarations- Optional ephemeral store declarations
Returns: Doc<D, E> - A typed document with:
- Direct field access (e.g.,
doc.title,doc.todos) - Mutation methods on refs (e.g.,
doc.title.insert(),doc.todos.push())
For sync operations, use sync(doc):
sync(doc).waitForSync()- Wait for network syncsync(doc).presence- Access presence storesync(doc).peerId- Get the local peer ID
Subscribes to document or ref changes and returns the current value. Re-renders when the value changes.
// Full document snapshot
const snapshot = useValue(doc)
// Single ref value
const title = useValue(doc.title)
const todos = useValue(doc.todos)
// With selector (fine-grained updates)
const todoCount = useValue(doc, d => d.todos.length)Parameters:
docOrRef: Doc<D> | AnyTypedRef- The document or typed ref to subscribe toselector?: (value: Infer<D>) => R- Optional selector function (for documents only)
Returns: The current value or selected value
Returns the placeholder value for a typed ref, as defined in the schema.
const placeholder = usePlaceholder(doc.title)
// Returns the value from Shape.text().placeholder("Enter title...")Parameters:
ref: AnyTypedRef- A typed ref (TextRef,ListRef, etc.)
Returns: The placeholder value from the schema, or undefined if not defined
Creates a Lens from a world TypedDoc and returns both the lens and a reactive JSON snapshot
of the lens worldview. Uses the same snapshot caching behavior as useValue to avoid
unnecessary renders.
const doc = useDocument(docId, DocSchema)
const { lens, worldview } = useLens(doc, {
filter: info => info.message?.userId === myUserId,
})
// Optional selector form
const { lens, worldview: title } = useLens(doc, undefined, d => d.title)Parameters:
world: TypedDoc<D>- The world document (source) for the lensoptions?: LensOptions- Optional lens configuration (e.g., filter)selector?: (doc: Infer<D>) => R- Optional selector for fine-grained updates
Returns: { lens: Lens<D>; worldview: Infer<D> | R }
Subscribes to any ephemeral store and returns the current state.
const { self, peers } = useEphemeral(sync(doc).presence)
// Or for other ephemeral stores:
const { self, peers } = useEphemeral(sync(doc).cursors)Parameters:
ephemeral: TypedEphemeral<T>- A typed ephemeral store
Returns: { self: T | undefined, peers: Map<string, T> }
Syncs document ID with the URL hash, enabling shareable links (e.g., https://app.example.com/#doc-abc123).
import { useDocIdFromHash, useDocument } from "@loro-extended/react"
import { generateUUID } from "@loro-extended/repo"
function App() {
// If URL has no hash, generates a new ID and writes it to the hash
// If URL has a hash, uses that as the document ID
const docId = useDocIdFromHash(() => `doc-${generateUUID()}`)
const doc = useDocument(docId, MySchema)
// ...
}Parameters:
generateDefaultDocId: () => DocId- A function that generates a default document ID when the URL hash is empty
Returns: DocId - The current document ID from the URL hash
Features:
- Uses
useSyncExternalStorefor React 18+ concurrent mode safety - SSR-safe with server snapshot support
- Automatically writes hash on mount if empty
- Caches generated default ID across renders (generator is only called once)
- Reacts to
hashchangeevents for browser navigation
Utility Functions:
The package also exports pure utility functions for custom implementations:
import { parseHash, getDocIdFromHash } from "@loro-extended/hooks-core"
// Remove '#' prefix from hash string
parseHash("#my-doc") // => "my-doc"
parseHash("my-doc") // => "my-doc"
// Get docId from hash with fallback
getDocIdFromHash("#my-doc", defaultId) // => "my-doc"
getDocIdFromHash("", defaultId) // => defaultIdCreates hooks for collaborative text editing.
import { createTextHooks } from "@loro-extended/hooks-core"
import * as React from "react"
export const { useCollaborativeText } = createTextHooks(React)Binds an HTML input or textarea to a Loro text container with bidirectional sync and cursor preservation.
function CollaborativeInput({ textRef }: { textRef: TextRef }) {
const { inputRef, defaultValue, placeholder } = useCollaborativeText(textRef)
return (
<input
ref={inputRef}
defaultValue={defaultValue}
placeholder={placeholder}
/>
)
}Cursor Behavior:
- Local changes: Cursor position is calculated based on the input type (insert, delete, etc.)
- Remote changes: Uses delta-based adjustment to preserve cursor position relative to content. When a remote peer inserts or deletes text before your cursor, your cursor moves appropriately to stay in the same logical position.
- IME composition: Properly handles input method editors for CJK and other languages
Options:
onBeforeChange?: () => boolean | undefined- Called before applying a local change. Returnfalseto prevent the change.onAfterChange?: () => void- Called after any change (local or remote) is applied.
Creates hooks for undo/redo management.
import { createUndoHooks } from "@loro-extended/hooks-core"
import * as React from "react"
export const { useUndoManager } = createUndoHooks(React)Manages undo/redo with Loro's UndoManager. Automatically sets up keyboard shortcuts.
function Editor({ doc }: { doc: Doc<DocSchema> }) {
const { undo, redo, canUndo, canRedo } = useUndoManager(doc)
return (
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
</div>
)
}Options:
mergeInterval?: number- Time in ms to merge consecutive changes (default: 500)enableKeyboardShortcuts?: boolean- Enable Ctrl/Cmd+Z and Ctrl/Cmd+Y (default: true)getCursors?: () => Cursor[]- Callback to capture cursor positions before undo stepssetCursors?: (positions: Array<{ offset: number; side: -1 | 0 | 1 }>) => void- Callback to restore cursor positions after undo/redonamespace?: string- Namespace for isolated undo stacks (see Namespace-Based Undo below)
Namespace-Based Undo:
When building forms or editors with multiple independent text fields, you may want each field to have its own undo stack. Use the namespace option to isolate undo operations:
function FormEditor({ doc }: { doc: Doc<FormSchema> }) {
// Each field gets its own undo stack
const { undo: undoTitle } = useUndoManager(doc, { namespace: "title" })
const { undo: undoBody } = useUndoManager(doc, { namespace: "body" })
// Undo in title field won't affect body field
}Important: Register all namespaces before making changes. If you register a namespace after other managers exist, a warning will be logged. This is because
excludeOriginPrefixesis calculated at manager creation time and cannot be updated afterward.
Cursor Restoration Example:
function EditorWithCursorRestore({ doc, textRef }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const loroText = loro(textRef).container
const { undo, redo, canUndo, canRedo } = useUndoManager(doc, {
getCursors: () => {
const input = inputRef.current
if (!input) return []
const pos = input.selectionStart ?? 0
const cursor = loroText.getCursor(pos)
return cursor ? [cursor] : []
},
setCursors: (positions) => {
const input = inputRef.current
if (!input || positions.length === 0) return
const pos = positions[0].offset
input.setSelectionRange(pos, pos)
},
})
// ... rest of component
}import { Shape, change } from "@loro-extended/change"
import { sync } from "@loro-extended/repo"
import { useDocument, useValue, useEphemeral } from "@loro-extended/react"
const DocSchema = Shape.doc({
title: Shape.text().placeholder("Untitled"),
items: Shape.list(Shape.plain.string()),
})
const PresenceSchema = Shape.plain.struct({
cursor: Shape.plain.struct({
x: Shape.plain.number(),
y: Shape.plain.number(),
}),
name: Shape.plain.string().placeholder("Anonymous"),
})
function MyComponent({ docId }) {
// Get typed document with ephemeral stores
const doc = useDocument(docId, DocSchema, { presence: PresenceSchema })
// Subscribe to document snapshot
const snapshot = useValue(doc)
// Subscribe to presence
const { self, peers } = useEphemeral(sync(doc).presence)
// Mutate document
const addItem = (text) => {
change(doc, d => {
d.items.push(text)
})
}
// Update presence
const updateCursor = (x, y) => {
sync(doc).presence.setSelf({ cursor: { x, y } })
}
return (
<div>
<h1>{snapshot.title}</h1>
<ul>
{snapshot.items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<div>
{Array.from(peers.values()).map(p => (
<span key={p.name}>{p.name}</span>
))}
</div>
</div>
)
}-
Stable References - The document never changes identity, so you can safely pass it to child components or use it in callbacks without causing re-renders.
-
Separation of Concerns - Reading (
useValue) and writing (change()or direct mutation) are clearly separated. -
Fine-Grained Reactivity - Subscribe to specific refs or use selectors to only re-render when specific data changes:
// Only re-renders when title changes const title = useValue(doc.title) // Only re-renders when todo count changes const count = useValue(doc, d => d.todos.length)
-
Unified Sync API - Use
sync(doc)for all sync-related operations (presence, waitForSync, etc.). -
Type Safety - Full TypeScript support with proper type inference from schemas.
@loro-extended/react- React bindings@loro-extended/hono- Hono/JSX bindings@loro-extended/change- Schema definitions@loro-extended/repo- Document synchronization
MIT