CRDT LF Flutter
Flutter reactivity for crdt_lf: rebuild your
widgets when CRDT state changes — at the document level or scoped to a single
handler, with selectors and a collaborative text field.
Built on top of provider — so you also get
context.read / context.watch / context.select for a CRDTDocument for free
(provider is re-exported minimally).
- CRDT LF Flutter
Features
CrdtProvider— provide aCRDTDocumentto the subtree, created and owned by the provider (CrdtProvider(create:)) or caller-owned (CrdtProvider.value(value:)). Wrapsprovider'sInheritedProvider.CrdtBuilder/CrdtSelector<R>— rebuild on every document update, or only when a selected slice changes.CrdtHandlerBuilder<H>/CrdtHandlerSelector<H, R>— rebuild only when a specific handler changes (optionally including nested handlers).CrdtHandlerListener<H>— side-effect callback on a handler change.CrdtHandlerDeltaBuilder<V, D>/CrdtHandlerDeltaListener<V, D>— follow what a handler did, change by change. The builder keeps the value and rebuilds with it already moved; the listener hands you each change as a side effect and never rebuilds the subtree.CrdtTextFieldBuilder— aTextEditingControllerbound to a text handler, the way collaborative editor bindings work.CrdtTextCursorsOverlay— paints collaborators' carets/selections over the text field, anchored by stable positions.CrdtAwarenessCursorsOverlay— overlays collaborators' mouse-style presence cursors (pointer arrow + name bubble) on any pane. Split into aCrdtAwarenessCursorsBuilder(positioning + local-pointer handling, you build each marker) and a standaloneCrdtAwarenessCursorMarkerwidget, styled throughCrdtAwarenessCursorStyle.- Context helpers:
context.crdtDocument,context.watchCrdtDocument(),context.selectCrdtDocument(...),context.crdtHandler<H>(id).
Greyhound Markdown
A real-time collaborative markdown editor built with crdt_lf and
crdt_lf_flutter. Open it on separate devices, join the same room and edit
together — no install needed.
Source: apps/greyhound_markdown.
Example
The example/
app demos every widget in this package on a single screen — each card wires
a different handler, and a live ⟳ rebuilt ×N badge shows exactly which regions
re-render as you edit, so you can see the reactive scoping (and the
zero-rebuild bindings) at work.
Getting Started
dependencies:
crdt_lf_flutter:
crdt_lf:
Usage
Provide a document
// Owning mode: the provider creates the document lazily and disposes it.
CrdtProvider(
create: (_) => CRDTDocument(),
child: const MyApp(),
);
// Value mode: you own the document lifecycle.
CrdtProvider.value(value: doc, child: const MyApp());
Document-level rebuilds
// Rebuild on any change.
CrdtBuilder(
builder: (context, document) => Text(textHandler.value),
);
// Rebuild only when a derived slice changes.
CrdtSelector<int>(
selector: (context, document) => listHandler.value.length,
builder: (context, count) => Text('$count todos'),
);
Handler-scoped rebuilds
Rebuild only when one handler changes — unrelated handlers don't trigger a
rebuild. CrdtHandlerBuilder hands you the concrete typed handler (the base
Handler exposes no value), which is also the right tool for list/map handlers
whose value is mutated in place (a value Selector wouldn't detect the change).
CrdtHandlerBuilder<CRDTListHandler<String>>(
id: 'todos',
builder: (context, handler) => Text('${handler.value.length}'),
);
// Derived slice from a handler.
CrdtHandlerSelector<CRDTListHandler<String>, int>(
id: 'todos',
selector: (context, handler) => handler.value.length,
builder: (context, count) => Text('$count'),
);
// Also rebuild when a nested/descendant handler changes.
CrdtHandlerBuilder<CRDTMapRefHandler>(
id: 'root',
nested: true,
builder: (context, handler) => ...,
);
// Side effects only (no rebuild).
CrdtHandlerListener<CRDTListHandler<String>>(
id: 'todos',
listener: (context, handler) => showSnackBar(...),
child: ...,
);
What changed, not just that it changed
Start with CrdtHandlerBuilder: it hands you the handler, you read
handler.value, done. That read is cache-backed — an edit advances the cached
state instead of replaying the history — so for most values it is already the
right answer, and there is nothing to keep in sync.
The widgets below are for when re-reading is the expensive part, or when the
value alone does not tell you enough. CrdtHandlerDeltaListener reports what
moved, one call per change, so you can advance a projection you already hold —
drive an AnimatedList, replay an edit into a controller, append to a log.
CrdtHandlerDeltaListener<List<String>, SequenceDelta<String>>(
id: 'todos',
// Seed here: fires once on subscribe, and again whenever the value has to
// be taken fresh (a snapshot import, a dropped cache).
onReset: (context, sync, cause) => _items = sync.value,
// One call per change, with the retain/insert/delete of that change.
onDelta: (context, event) => _items = event.delta.apply(_items),
child: const TodoList(),
);
The two type arguments are the handler's value and its delta:
SequenceDelta<T> for the text and list handlers, MapDelta<K, V> for the
maps, SetDelta<T> for the OR-set, RegisterDelta<T> for the register.
The widget handles the awkward part of the contract for you: a reset means
"read it again", and the events the fresh read already holds must not be
applied on top of it. It performs the read and drops those events, so onDelta
never reports a change twice. The value sync.value hands over is yours to
keep — no copy needed.
Like the other listener, it renders child unchanged and never rebuilds the
subtree.
Putting the value on screen
When the value is what you render, CrdtHandlerDeltaBuilder does the holding
for you. It seeds itself while attaching, so the first frame already shows the
document, and then moves the value by the delta of each change:
CrdtHandlerDeltaBuilder<List<String>, SequenceDelta<String>>(
id: 'todos',
builder: (context, todos) => ListView(
children: [for (final todo in todos) Text(todo)],
),
);
It rebuilds once per change, exactly like CrdtHandlerBuilder. The difference
is only what the rebuild costs: the value arrives already moved by the delta,
instead of being projected again from the document. On a long text or a large
list that is the whole point; on a handful of keys it buys nothing.
Which one to reach for:
| You want | Widget | What the builder gets |
|---|---|---|
| the value on screen — the default | CrdtHandlerBuilder |
the handler; read handler.value |
| the same, without re-reading the value each time | CrdtHandlerDeltaBuilder |
the value, already moved |
| to know where it changed, or to not rebuild at all | CrdtHandlerDeltaListener |
the delta of each change |
All three give you the value one way or another. The first two put it on screen and differ only in cost; the third is the one that hands you the change itself, and never rebuilds its subtree.
Imperative access
For actions (insert/delete/change) you don't need reactivity — read the handler once:
context.crdtHandler<CRDTListHandler<String>>('todos').insert(0, 'new');
Collaborative text
CrdtTextFieldBuilder binds a TextEditingController to the text handler
(CRDTTextHandler or CRDTFugueTextHandler) registered under id, the way
collaborative editor bindings do:
- local edits are pushed into the handler immediately as the precise delta of each editing gesture (prefix/suffix trimming, one transaction per gesture — no full-text diff, no debounce);
- IME composition (CJK input, autocorrect) is respected: nothing is committed while a composing region is active;
- remote changes are adopted into the controller in place, with the caret
and selection kept anchored: with a
CRDTFugueTextHandlerthrough stable positions (stablePositionAt— anchors tied to element identity, exact even for multi-region remote changes), otherwise mapped through the remote delta; - the subtree never rebuilds —
builderruns once and the controller is updated directly.
CrdtTextFieldBuilder(
id: 'note',
builder: (context, controller) => TextField(controller: controller),
);
The delta primitives are exported too (TextDelta, computeTextDelta,
mapOffsetThroughDelta) if you need to build a custom binding.
Remote text cursors
Publish the local selection with onSelectionAnchorsChanged (the anchors are
serializable — send them over your presence channel, e.g. the awareness
plugin of crdt_socket_sync) and draw collaborators with
CrdtTextCursorsOverlay. Anchors are reported only while the field has
focus — on blur the callback fires once with nulls, so with several bound
fields a peer shows at most one cursor, where they are typing:
CrdtTextFieldBuilder(
id: 'note',
onSelectionAnchorsChanged: (base, extent) => publishPresence(base, extent),
builder: (context, controller) => CrdtTextCursorsOverlay(
id: 'note',
cursors: remoteTextCursors, // List<CrdtTextCursor> from presence
child: TextField(controller: controller),
),
);
Rune ↔ UTF-16 offsets
crdt_lf's handler API is rune-indexed everywhere. Flutter's RenderEditable, TextSelection and
TextPosition are not — they count UTF-16 code units.
CrdtTextFieldBuilder and CrdtTextCursorsOverlay already do this
conversion for you; you only need it yourself when building a custom text
binding.
The single conversion point is RuneOffsets, defined in crdt_lf itself
and reused here as-is — deliberately not reimplemented or wrapped in this
package:
RuneOffsets.utf16Offset('a😀b', 1); // 1 — start of the emoji
RuneOffsets.utf16Offset('a😀b', 2); // 3 — start of 'b'
RuneOffsets.runeIndex('a😀b', 3); // 2 — the rune that starts at code unit 3
utf16Offset converts a handler rune index into a UTF-16 offset for
TextSelection/TextPosition; runeIndex converts the other way. Both
clamp out-of-range input to the ends of the string instead of throwing.
Presence cursors
CrdtAwarenessCursorsOverlay draws collaborators' mouse pointers (arrow
plus name bubble) over any pane — the presence complement of the in-field
text cursors above. It is transport-agnostic: you map your presence channel
to CrdtAwarenessCursors (positions normalized into [0, 1], so cursors
map across window sizes) and publish what onLocalPointer reports:
CrdtAwarenessCursorsOverlay(
cursors: remotePointers, // List<CrdtAwarenessCursor> from presence
onLocalPointer: (position, {required hovering}) =>
publishPresence(position, hovering),
child: pane,
);
The overlay is composed of three pieces, all exported so you can use exactly the layer you need:
CrdtAwarenessCursorsBuilder— the transport/layout half: it positions each pointer over the pane and reports the local pointer, but delegates each cursor's look to abuildercallback. Use it to draw a completely custom marker (an avatar, a badge, …) per cursor.CrdtAwarenessCursorMarker— the standalone default marker (arrow + name bubble) for one cursor, positioning-agnostic.CrdtAwarenessCursorsOverlay— the ready-made combination of the two above.
The marker is styled Container-style: pass a plain color for the
common case, or a full CrdtAwarenessCursorStyle (the "decoration") — its
color plus the label text style and marker sizes — when you need more
(passing both is an error). Pass a style to CrdtAwarenessCursorsOverlay to
restyle every cursor at once, or set CrdtAwarenessCursor.style for a single
peer; in the overlay the peer's own color always wins over the style's, so
peers keep their identity color:
CrdtAwarenessCursorsOverlay(
cursors: remotePointers,
style: const CrdtAwarenessCursorStyle(
pointerSize: 22,
labelStyle: TextStyle(fontSize: 12, color: Colors.white),
),
child: pane,
);
Benchmarks
This package includes a suite of benchmarks to ensure performance and stability. You can find the latest results here.
To run the benchmarks yourself, run from the repository root:
melos run benchmark_flutter
(deep dive) How it works
crdt_lf exposes a CRDTDocument.updates broadcast stream that fires on any
local edit, applied remote change or snapshot import. CrdtProvider exposes the
document through provider and bridges updates so
provider dependents (context.watch / context.select, and the widgets above)
rebuild automatically.
Handler-scoped widgets rebuild only when a per-handler signal changes: the O(1)
CRDTDocument.revisionForHandler(id), a monotonic revision that grows on every
applied change targeting the handler (local or imported) and on snapshot
imports carrying its state. With nested: true the ids and revisions of the
handler and its descendants (ContainerHandler.childRefs) are folded into one
hash, so structural changes (a child added or removed) are detected too.
The delta widgets — CrdtHandlerDeltaBuilder, CrdtHandlerDeltaListener and
CrdtTextFieldBuilder — work the other way round. They subscribe to the
handler's own watch() stream, which says what each change did, and they
keep a projection instead of re-reading the value. All three share one piece of
bookkeeping: which handler to follow, where the last read sits in the stream,
and which events that read already covers. A HandlerReset means "the base
moved, read it again"; the read comes back with the point of the stream it
reflects, so no event is ever applied twice.
Apps
- greyhound_markdown — Real-time collaborative markdown editor built on crdt_lf
Packages
Other bricks of the crdt "system" are:
Libraries
- crdt_lf_flutter
- Flutter reactivity for crdt_lf package.