DEV Community

Cover image for Generative UI Without the Async Tax: Introducing bloc_signals_genui 1.0

Generative UI Without the Async Tax: Introducing bloc_signals_genui 1.0

Comments praise pure-Dart determinism

Wait—Another Generative UI Package?

"An asynchronous network stream belongs at the perimeter of your architecture; once an AI agent's UI lands on the client, every keystroke, validation rule, and leaf rebuild should execute in 0ms on a synchronous signal graph."
— Randal L. Schwartz

When you see a new Generative UI announcement for Dart and Flutter, your first reaction is probably healthy skepticism: Do we really need another GenUI wrapper?

Most Generative UI prototypes stop at the initial parlor trick: an LLM streams a JSON payload over Server-Sent Events (SSE), a widget tree appears on screen, and everyone claps. The architectural cracks appear five seconds later when a user tries to interact with that generated interface:

  • The Everything-Is-Async Tax: Because the LLM response arrives over an asynchronous network stream, most GenUI libraries wire the entire client lifecycle through StreamBuilder, async* generators, or asynchronous controller notifications. Every keystroke in a generated text field, every cross-field validation check, and every widget test pays a microtask and frame-delay tax.
  • Framework Lock-In: Most GenUI state engines are coupled directly to package:flutter/widgets.dart (BuildContext, Widget, ChangeNotifier), making it impossible to run or test the agent state machine in pure Dart, CLI tools, or terminal interfaces—and forcing you to adopt a bespoke state management paradigm that clashes with your existing Riverpod or flutter_bloc app.

Today, we are publishing v1.0.0 of bloc_signals_genui and bloc_signals_genui_flutter on pub.dev. Here is what sets them apart:

  • 100% Compliant with Google's Emerging A2UI Protocol: Built directly on Google's official package:a2ui_core (^0.3.0) library from the a2ui-project/a2ui specification (v0.9.1 and v1.0).
  • 0ms Synchronous Core & Live Two-Way Form Bindings: The async SSE stream is quarantined at the edge. Once on the client, two-way WritableBinding form inputs, synchronous CheckRule validation, and NodeResolver list templates execute in 0ms in the exact same frame.
  • True Pure-Dart Decoupling (From Flutter to Terminal TUIs): bloc_signals_genui has zero Flutter dependencies. The exact same A2uiSurfaceBloc state machine drives both a 120 FPS Material 3 Flutter app and an interactive ANSI Terminal User Interface (TUI).
  • Universal Interop (Keep Your Riverpod or Classic BLoC Core): Through bloc_signals_riverpod and bloc_signals_bloc, you can drop A2UI surfaces straight into an existing Riverpod or classic BLoC codebase without rewriting your architecture.

Foundation: Official A2UI Compliance and Shared Reactive DNA

We avoided inventing a custom JSON schema or maintaining a divergent protocol parser. Both packages build directly on Google's official package:a2ui_core Dart SDK for the A2UI (Agent-to-User Interface) protocol. (We even contributed upstream in a2ui-project/a2ui#2819 to lower a2ui_core's minimum SDK constraint to Dart ^3.5.0, opening the official A2UI engine to the entire Flutter 3.24+ ecosystem.)

When inspecting a2ui_core under the hood, one architectural detail stands out immediately: Google's a2ui_core already standardizes on preact_signals for its internal DataModel, GenericBinder, and reactive path bindings.

Wrapping a signal-powered protocol engine inside asynchronous StreamController pipes adds unnecessary translation layers. Because BlocSignal is built on the exact same reactive signal primitives, bloc_signals_genui connects to a2ui_core with zero impedance mismatch:

flowchart TD
    Agent["AI Agent / LLM Backend\n(Streams A2UI v0.9 / v1.0 JSON via SSE)"]
    Bloc["Edge Container: A2uiSurfaceBloc (Pure Dart)\n• Quarantines async stream with restartable() mutex\n• Sealed lifecycle: Initial → Streaming → Ready → Submitting\n• Bounded multi-surface LRU cache (maxSurfaces)"]
    Core["Synchronous Reactive Core (a2ui_core + BlocSignal)\n• 0ms Two-Way WritableBinding DataModel sync\n• Synchronous CheckRule validation (isValid / errorText)\n• Reactive NodeResolver ChildList template expansion"]
    Flutter["Flutter Renderer (bloc_signals_genui_flutter)\n• 16 Material 3 BasicCatalog widgets\n• Surgical leaf rebuilds via GenericBinder signals"]
    TUI["ANSI Terminal Renderer (examples/genui_tui_agent)\n• UTF-8 box drawing & bracketed hotkeys\n• Interactive stdin prompts & CheckRule badges"]
    Interop["Grand Central Interop\n• Riverpod 2/3 (.toProvider())\n• Classic BLoC & OpenTelemetry spans"]

    Agent -->|"1. Async JSON Chunks (Network Perimeter)"| Bloc
    Bloc -->|"2. 0ms Same-Frame Signal Graph"| Core
    Core --> Flutter
    Core --> TUI
    Core --> Interop

Pillar 1: A Synchronous Core with Live Two-Way Form Bindings

In a real application, an AI-generated surface functions as a live interactive form, not a static poster. When a flight-booking agent streams a checkout card, the user still needs to type passenger names, toggle baggage checkboxes, pick seat classes, and see instant validation feedback before submitting their selection back to the agent.

bloc_signals_genui and bloc_signals_genui_flutter support the full 16-component A2UI BasicCatalog (Text, Row, Column, List, Tabs, Modal, Card, Divider, Icon, Image, Button, TextField, CheckBox, ChoicePicker, Slider, and DateTimeInput) with four synchronous capabilities:

1. Two-Way WritableBinding Synchronization in 0ms

When an A2UI component binds a property to a JSON Pointer path in the surface's DataModel (for example, {"path": "/user/name"} in v0.9 or {"@path": "/user/name"} in v1.0), A2uiComponentContext exposes resolveBoundPath and updateBoundValue. Typing into an A2uiTextField, sliding an A2uiSlider, or picking a chip in A2uiChoicePicker mutates the underlying DataModel signal synchronously in 0ms. Only the leaf widget observing that path rebuilds—leaving the parent surface scaffold untouched.

2. Same-Frame CheckRule Validation UI

A2UI allows components to declare client-side checks rules right in the wire JSON. As the user edits a bound field, GenericBinder evaluates those CheckRule conditions synchronously on every keystroke. A2uiComponentContext exposes isValid and validationErrors, displaying inline errorText on invalid inputs and automatically disabling A2uiButton submissions (onPressed: null) in the exact same frame when required checks fail—with zero LLM round-trips.

3. Data-Driven NodeResolver List Templates

Instead of burning LLM output tokens streaming 20 repetitive component subtrees for a list of flights, the agent streams a single template component (flight_card) and a ChildList binding ({"componentId": "flight_card", "path": "/flights"}). A2uiSurfaceView mounts a2ui_core's NodeResolver to dynamically expand and scope each item's data context (/flights/0, /flights/1, ...) reactively as array elements stream in.

4. Bidirectional RPC (RpcHandler)

Beyond standard action submissions (SubmitAction), A2uiSurfaceBloc wires MessageProcessor.rpc so the agent can invoke client-side renderer functions (CallRendererFunctionMessage) and your Dart code can invoke agent-side functions via bloc.callAgentFunction(...) with built-in timeout and lifecycle cancellation safety.

import 'package:bloc_signals_genui/bloc_signals_genui.dart';

// 1. Create the pure-Dart A2UI state machine
final surfaceBloc = A2uiSurfaceBloc(
  maxSurfaces: 10,
  maxResponseHistory: 100,
);

// 2. Ingest an incoming A2UI v0.9 stream (quarantined by restartable())
surfaceBloc.add(IngestStream(llmChunkStream));

// 3. Mutate bound form fields synchronously in 0ms
surfaceBloc.add(
  const UpdateFormField(
    path: '/user/name',
    value: 'Randal L. Schwartz',
  ),
);
Enter fullscreen mode Exit fullscreen mode

And because state transitions and form bindings are synchronous, your Flutter widget tests verify updates in a single tester.pump() without waiting on pumpAndSettle() microtask queues.


Pillar 2: Streamless Concurrency and Production Scars Built In

Streaming UI from an LLM introduces failure modes that traditional CRUD screens never encounter:

  1. Impatient Users & Mid-Stream Interruptions (restartable()): What happens when an agent is halfway through streaming a complex itinerary surface and the user changes their mind and sends a new prompt? In naive stream setups, both streams interleave and corrupt the component tree. A2uiSurfaceBloc guards IngestStream with BlocSignal's streamless restartable() mutex transformer—subscribing to the replacement stream, preempting the stale stream immediately, and cleaning up resources without race conditions.
  2. Zero-Chunk Stream Failures & Turn-Alternation Protection: Conversational APIs (such as Google Gemini and Firebase Genkit) enforce strict user → model turn alternation. If your app optimistically appends a user prompt to chat history and the network drops before the first SSE chunk arrives (chunkCount == 0), leaving that unfulfilled user turn in history causes the user's next retry to crash with INVALID_ARGUMENT: Consecutive user turns are not allowed. Our architecture and reference apps bake in transactional zero-chunk turn pruning out of the box.
  3. Multi-Surface Navigation & Bounded LRU Eviction: Agents frequently spawn multiple surfaces across a conversation (main workspace, comparison cards, detail modals). A2uiSurfaceBloc separates navigation coordinates (activeSurfaceId, a 0ms ReadonlySignal<String?>) from per-surface content revisions (_surfaceVersions), so switching tabs via SelectSurface never triggers redundant binder rebuilds in split-pane views—while maxSurfaces automatically evicts the least-recently-used non-active surfaces so long sessions never leak memory.

Pillar 3: Pure-Dart Decoupling—Running the Exact Same Agent in a Terminal TUI

Because bloc_signals_genui has zero dependency on Flutter, your entire Generative UI state machine can run in CLI tools, backend Dart servers, automated test harnesses, or terminal applications.

To prove this architecture in the monorepo, we built two complete reference applications powered by the exact same A2uiSurfaceBloc and BasicCatalog payloads:

  1. examples/genui_flight_booking: A Material 3 Flutter application rendering ChoicePicker seat chips, CheckBox preferences, DateTimeInput pickers, NodeResolver flight card templates (/flights), CheckRule passenger validation, and assignPreferredSeat RPC calls.
  2. examples/genui_tui_agent: An interactive ANSI Terminal User Interface (TUI) inspired by agy-cli that renders those exact same 16 BasicCatalog components, template paths, and CheckRule validation badges using UTF-8 box-drawing characters (┌─┐, │, └─┘), interactive stdin prompts, and bracketed action hotkeys ([1] Search Flights).
# Run the interactive ANSI Terminal TUI agent in pure Dart:
dart run examples/genui_tui_agent/bin/main.dart --mock
Enter fullscreen mode Exit fullscreen mode

Decoupling the A2UI state machine from the renderer means you can verify and fuzz-test complex multi-turn agent flows in pure dart test in milliseconds, without spinning up a simulator or headless browser.


Pillar 4: "Grand Central Station" Interop—Keep Your Riverpod or Classic BLoC Core

Adopting a Generative UI library should not require rewriting your application's existing state management architecture.

Because A2uiSurfaceBloc extends BlocSignal, it plugs directly into the rest of the 13-package BlocSignal ecosystem:

  • Using Riverpod? Keep Your Riverpod Core: Add bloc_signals_riverpod and call .toProvider(autoClose: true) on A2uiSurfaceBloc. Your Riverpod ConsumerWidgets and providers can watch the A2UI surface state or dispatch events through ref while A2uiSurfaceView handles the granular signal rendering:
import 'package:bloc_signals_genui/bloc_signals_genui.dart';
import 'package:bloc_signals_riverpod/bloc_signals_riverpod.dart';
import 'package:riverpod/riverpod.dart';

// Expose A2uiSurfaceBloc as a native Riverpod NotifierProvider
final surfaceBloc = A2uiSurfaceBloc(maxSurfaces: 5);
final a2uiSurfaceProvider = surfaceBloc.toProvider(autoClose: true);

// Inside any Riverpod consumer:
// final surfaceState = ref.watch(a2uiSurfaceProvider);
// ref.read(a2uiSurfaceProvider.notifier).bloc.add(IngestStream(stream));
Enter fullscreen mode Exit fullscreen mode
  • Using Classic flutter_bloc? Bridge It cleanly: Use bloc_signals_bloc to bridge events and states bidirectionally with classic Bloc and Cubit instances.
  • Want Production Telemetry? Attach OpenTelemetry & DevTools: Drop in bloc_signals_otel and bloc_signals_devtools to trace every A2UI stream ingestion, chunk transition, and restartable() preemption span in OpenTelemetry and the Flutter DevTools extension with zero changes to your UI code.

Get Started Today

Both bloc_signals_genui (1.0.0) and bloc_signals_genui_flutter (1.0.0) are live on pub.dev with 100% test coverage and full support for Dart 3.5+ through Dart 3.13+:

# For pure-Dart, CLI, or TUI applications:
dart pub add bloc_signals_genui bloc_signals

# For Flutter applications:
flutter pub add bloc_signals_genui_flutter bloc_signals_genui bloc_signals_flutter
Enter fullscreen mode Exit fullscreen mode

In follow-up articles in this series, we will dive deeper into each of these four pillars—including a hands-on walkthrough of building two-way validated A2UI forms with WritableBinding and CheckRule, wiring NodeResolver templates, and building headless/TUI agent runners in pure Dart.

Links & Resources

Top comments (13)

Collapse
 
alexcodebytes profile image
Oleksandr •

The "async tax" framing is the right one — most GenUI libraries treat every state update as a network event, so validation and cross-field checks all pay a microtask cost that a real UI shouldn't.

One thing I'd push on: the "0ms synchronous core" claim is technically accurate but only if the DataModel is small. If the A2UI surface binds to a deeply nested JSON tree (say 200 flights with 15 fields each), a resolveBoundPath walking the full tree on every keystroke could be worse than async — because it blocks the frame.

Curious: does a2ui_core use a flat path index (path → signal) or does it traverse the tree on every read? Asking because the difference is the difference between "0ms" and "0ms until you hit a real dataset".

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Great question and pushback, Oleksandr!

Here is how the physical plumbing actually works in a2ui_core and bloc_signals_genui_flutter, and why you don't pay an O(N) tree-walk penalty on every keystroke:

  1. DataModel uses a flat path signal registry:
    Inside a2ui_core's DataModel, signals are indexed directly by their normalized JSON Pointer string in a hash map (Map<String, WeakReference<Signal<Object?>>> _signals). When a component sets up a binding to /flights/42/passengerName, dataModel.watch('/flights/42/passengerName') registers a direct signal pointer for that exact path key in O(1).

  2. Reads on keystrokes do NOT walk the tree:
    resolveBoundPath('value') in A2uiComponentContext is purely string manipulation (segment prefixing for relative paths like flight_card templates). When you type into an A2uiTextField, the widget doesn't re-scan the 200 flights or walk the DOM; it emits UpdateFormField('/flights/42/passengerName', value).

  3. Writes mutate in-place and notify strictly by path prefix:
    When dataModel.set(path, value) executes, it navigates the path segments directly to the target node, mutates in-place, and _notifyPathAndRelated(dataPath) iterates only through active registered watch paths (_signals.keys). If you have 200 flights with 15 fields each (3,000 fields), but only 1 flight card's name input is being edited, _signals only notifies the exact changed path and any prefix ancestors/descendants observing that slice.

  4. Leaf-level rebuilds via GenericBinder:
    Because each component's GenericBinder subscribes directly to its own resolvedProps signal, only the specific TextField whose path changed marks itself dirty. The surrounding 199 flight cards don't even run their build methods.

So it really is O(1) direct signal notification per keystroke, rather than an unindexed full-tree traversal.

Collapse
 
alexcodebytes profile image
Oleksandr •

The flat path signal registry (Map<String, WeakReference<Signal>>) is exactly the design I was hoping for, and it settles my concern about the "0ms" claim — O(1) on a hash lookup, not tree traversal.

But the WeakReference part is the one I'd dig into: if the signal for a path is weakly referenced and gets GC'd, the next write to that path has to re-create it. Does set() handle "path exists, signal doesn't" gracefully (re-create + notify), or is there a case where a keystroke lands before the signal is re-registered and the write is silently dropped?

Asking because weak refs + reactive signals is exactly the pattern where "works in dev, fails at scale" bugs live — you don't notice until the GC runs mid-session.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts • • Edited

Spot-on concern, Oleksandr. WeakReference bugs during GC sweeps are notoriously subtle, but here is why writes are never dropped:

  1. The underlying source of truth is _data, not the signals:
    When dataModel.set(path, value) is called, the write is applied directly and synchronously to the internal JSON tree (_data). It does not require a signal to exist in order to complete the mutation. _data is always 100% updated.

  2. A signal only exists if something is actively observing it:
    Signals are created when a mounted component calls watch(path). The mounted widget (via its GenericBinder) retains a strong reference to that signal as long as it is on screen. Therefore, as long as the input or observer widget is alive, the signal cannot be garbage collected.

  3. If the signal was GC'd, by definition nobody is listening:
    If a signal was GC'd, it means the widget unmounted and discarded its strong reference. When dataModel.set() runs:

  • _getAndNotify() checks ref.target. If it's null, it simply removes the dead key from _signals.
  • No notification is sent because there are zero active listeners to notify.
  • If a widget later mounts (or remounts) and calls watch(path) again, watch() reads the current value directly from _data via get(path) and creates a fresh signal with the up-to-date value.

So a keystroke write can never be silently dropped—the data is written unconditionally, and the weak reference only manages observer lifecycle cleanup without leaking memory when surfaces unmount.

Thread Thread
 
alexcodebytes profile image
Oleksandr •

That's a clean answer — "_data is the source of truth, signals are just observer handles" is the distinction I was hoping for, and it's the right way to build it.

The "if it was GC'd, by definition nobody is listening" logic holds, but there's one edge case I'd want to be sure about: what if a widget mounts, calls watch(path), receives a signal, and then the signal is GC'd while the widget is still mounted? That would require the widget to hold a weak reference to its own signal, which would be a bug — but it's the exact class of bug that only shows up under memory pressure in production, not in tests.

Is the widget's reference to the signal strongly typed (Signal not WeakReference>)? Asking because if the GenericBinder stores a strong ref, then the GC scenario can't happen, and the design is airtight.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Yes, exactly: it is airtight because GenericBinder holds a strong reference.

Here is the exact chain in the code:

  1. GenericBinder holds a strong closure subscription: When GenericBinder binds a property (in _resolveAndBind), it calls:
final ReadonlySignal<Object?> sig = context.dataContext.resolveListenable(value);
_subscriptions.add(
  sig.subscribe((newValue) {
    _updateDeepValue(path, newValue);
  }),
);
Enter fullscreen mode Exit fullscreen mode
  1. The subscription closure retains the signal:
    sig.subscribe(...) attaches an observer callback to sig. In preact_signals, the subscription actively anchors sig in memory. Furthermore, GenericBinder stores the unsubscription teardown function in its own _subscriptions list (List<void Function()>).

  2. The Flutter widget / component context holds the GenericBinder:
    The mounted widget element holds its A2uiComponentContext which holds the GenericBinder which holds _subscriptions.

As long as the widget is mounted on screen, the strong reference root (Widget -> GenericBinder -> _subscriptions -> Signal) keeps the signal pinned. The WeakReference in DataModel._signals only exists so the global model doesn't keep orphaned signals alive once the widget unmounts and discards its GenericBinder.

Thread Thread
 
alexcodebytes profile image
Oleksandr •

The chain is exactly what I was hoping for: Widget → GenericBinder → _subscriptions → Signal, with the WeakReference only as the model-side observer list.

That means the "GC mid-session" bug I was worried about can't happen — if the widget is on screen, the signal is pinned. If it's not, nothing is listening anyway. Clean.

One last question, because this is the kind of thing that only shows up in production: if resolveListenable(value) is called but the underlying _data for that path doesn't exist yet (e.g. a new flight card was just added to the array), does it create an empty signal, throw, or return a null signal that gets bound lazily? Asking because "component mounts before its data exists" is the exact timing bug that breaks most reactive systems.

Thanks for walking through this — it's a masterclass in how to build a reactive UI layer.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

You nailed the exact timing gremlin that wrecks naive reactive trees!

The answer: it returns a live Signal(null), never throws, and binds completely lazily.

Under the hood in DataModel.watch(path):

  1. If _signals[path] already has an active signal, it returns it.
  2. If not, it calls get(path). If the path (or any intermediate segment in the array/map) doesn't exist in _data yet, get() simply evaluates safely to null.
  3. It instantiates final sig = signal<T?>(null) and registers it in _signals[path] = WeakReference(sig).
  4. The newly mounted component immediately renders its empty/loading state for null.
  5. When the agent's stream chunk finally lands a moment later with updateDataModel (e.g. populating that new flight card at /flights/0):
    • DataModel.set() applies the payload to _data.
    • _notifyPathAndRelated() scans the active signal keys, finds /flights/0 (or any parent/descendant path), and calls sig.value = get(path).
    • Because the mounted widget is already observing sig through GenericBinder, it re-renders with the populated flight data in the exact same synchronous frame.

So components can mount before their data exists, after their data exists, or data can arrive in arbitrary out-of-order SSE chunks. The signal is already pinned at the path address, waiting for the data to arrive.

Collapse
 
slabb profile image
Sam LABBE •

The quarantine-at-the-perimeter decision is bigger than the perf claim it's sold under: the async edge is also the determinism line. Everything inside the signal graph — sealed lifecycle, synchronous bindings, same-frame validation — becomes a pure-Dart state machine you can drive and fuzz from dart test in milliseconds, and that's exactly the half of an agent system that can hold a receipt: deterministic transitions, witnessed inputs. A StreamBuilder-tangled core isn't just slower per keystroke; it's why GenUI demos are untestable — the nondeterminism leaks into everything downstream of the first frame. Which is why the TUI reference app is the strongest part of the launch: the same machine driving an ANSI terminal is the proof the decoupling is real, not a claim about it.

One observation from the trust side, because A2UI puts it on the table: the CheckRules validating user input are authored by the same agent that generated the form. For UX gating — disabling a submit button on a bad email — that's fine, and 0ms is the right price. But the submission contract shouldn't inherit the authorship: data flowing onward deserves a validator that didn't write the form it validates. Same rule the critic threads keep finding, one new substrate down — a validator authored by the party it validates is a second speaker, not a check.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Sam, you nailed the core thesis in one sentence: "the async edge is also the determinism line."

That is exactly why we insisted on the TUI showcase in pure Dart. If a state machine cannot run in a terminal over stdin/stdout, it isn't decoupled; it's just a widget controller wearing architectural clothing.

And your trust observation on CheckRule authorship cuts straight to the root of agent safety:

"A validator authored by the party it validates is a second speaker, not a check."

Spot on. In our mental model:

  1. CheckRule in A2UI is strictly Client UX Ergonomics — preventing the user from waiting on an LLM network round-trip just to discover an empty required field or malformed email.
  2. But the Upstream Submission Contract must never trust dynamic wire rules. That is why A2uiSurfaceBloc exposes A2uiActionResponse back to the host environment: the host tool execution layer (or backend schema validator) must enforce deterministic domain constraints before any tool call executes against a database or external API.

You can't let the generator sign its own hall pass. Client-side checks give you 0ms feedback; host-side schema validators give you receipts.

Collapse
 
humam_moin profile image
Humam Moin •

The pure Dart approach is pretty interesting, especially being able to use the same logic in Flutter and terminal apps. I also like that it works with existing state management instead of forcing developers to change everything.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Thanks, Humam!

That exact combination—pure-Dart decoupling plus ecosystem interop—was the litmus test for whether we should build this.

So many AI/GenUI libraries accidentally box you into a corner: they pull in a massive UI dependency tree just to parse a message stream, and they force you to migrate your whole app's architecture just to try a single AI surface.

Proving that the exact same A2uiSurfaceBloc state machine can drive an ANSI terminal over stdin/stdout, render 120 FPS Material 3 in Flutter, and plug straight into a Riverpod or classic BLoC provider container means teams can adopt GenUI incrementally without rewriting their stack.

Collapse
 
humam_moin profile image
Humam Moin •

I like that it works with both Flutter and pure Dart without forcing you to change your existing setup. The terminal UI example is pretty cool too. Nice work!