The GenUI DataModel: Reactive UI State Without a State Manager
How the DataModel in Flutter's genui package turns AI-generated UI into a reactive system that rebuilds only what changed.
Published on • October 19, 2026
AI Assistant

When an AI generates your UI at runtime, you can’t hand-write the state layer for it. The genui package (0.10.4, published by labs.flutter.dev on pub.dev) solves this with the DataModel: a centralized, observable store that holds all dynamic UI state and rebuilds only the widgets bound to whatever changed. If you’ve used a provider or a riverpod-style store before, the mental model is familiar — but here the store is written by the model, not by you.
This post walks through how the DataModel fits into the GenUI runtime, how binding actually works on the wire, and how to write catalog widgets that subscribe to it correctly.
The runtime around the DataModel
The genui package documents five moving parts, and the DataModel sits in the middle of all of them:
Conversation— the facade your app talks to. It owns theSurfaceControllerand aTransport, and orchestrates the request/response loop.Catalog/CatalogItem— the vocabulary of widgets the AI is allowed to emit. Each item has a name, a JSON schema for its properties, and a builder function.DataModel— the observable store for dynamic UI state. Widgets bind to paths inside it.SurfaceController— the runtime engine: it applies incomingA2uiMessages (createSurface,updateComponents,updateDataModel,deleteSurface) and pushes results into the DataModel.A2uiTransportAdapter— parses raw LLM stream chunks into structured events viaA2uiParserTransformer.
The flow per turn is: you call conversation.sendRequest(), your onSend implementation streams LLM chunks into transport.addChunk(), the parser turns them into A2uiMessages, the SurfaceController applies them to the DataModel, and the Surface widgets listening to the controller rebuild. No setState in your own widget tree for any of that.
_controller = SurfaceController(catalogs: [CoreCatalogItems.asCatalog()]);
_transport = A2uiTransportAdapter(onSend: _onSendToLLM);
_conversation = Conversation(controller: _controller, transport: _transport);
_conversation.events.listen((event) {
if (event is ConversationSurfaceAdded) {
setState(() => _surfaceIds.add(event.surfaceId));
} else if (event is ConversationSurfaceRemoved) {
setState(() => _surfaceIds.remove(event.surfaceId));
}
});
Notice what setState is doing here: it only manages which surfaces exist in your list. The content inside each surface is entirely driven by the DataModel.
Binding: how widgets subscribe
The key design decision is granularity. Per the package README, “when data changes, only the widgets that depend on that specific piece of data are rebuilt.” That’s achieved through a DataContext handed to each widget’s builder function.
On the wire, a bound property isn’t a literal — it’s a reference. The AI can send a plain value for static content:
{
"id": "welcome-text",
"component": "Text",
"text": "Welcome to GenUI",
"variant": "h1"
}
or a path object that points into the DataModel instead. Input widgets make this a two-way street: when the user types into a TextField bound to /user/name, the DataModel updates, and every other widget bound to that same path rebuilds immediately — including ones the AI emitted three turns ago. That’s the “high-bandwidth interaction loop between the user, the UI, and the AI” the docs describe, and it means you rarely need to round-trip to the model just to reflect local input.
Writing a catalog item that binds correctly
If you extend the catalog with your own widget, subscription is explicit. Each CatalogItem gets a schema (via json_schema_builder) and a widgetBuilder that subscribes through context.dataContext:
final riddleCard = CatalogItem(
name: 'RiddleCard',
dataSchema: S.object(
properties: {
'question': S.string(description: 'The question part of a riddle.'),
'answer': S.string(description: 'The answer part of a riddle.'),
},
required: ['question', 'answer'],
),
widgetBuilder: (context) {
final question = context.dataContext.subscribeToString(
context.data['question'] as Map<String, Object?>?,
);
final answer = context.dataContext.subscribeToString(
context.data['answer'] as Map<String, Object?>?,
);
return ValueListenableBuilder<String?>(
valueListenable: question,
builder: (context, q, _) => ValueListenableBuilder<String?>(
valueListenable: answer,
builder: (context, a, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(q ?? '', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(a ?? '', style: Theme.of(context).textTheme.headlineSmall),
],
),
),
);
},
);
Two details worth copying into your own items:
- Handle nulls. The subscribe call returns a nullable value; a widget that renders before the model is populated must not throw.
- Register the item AND the prompt. Adding it to the controller is
SurfaceController(catalogs: [CoreCatalogItems.asCatalog().copyWith(newItems: [riddleCard])])— but the model won’t use it unless your system instruction (built withPromptBuilder.chatandsystemPromptFragments) names theCatalogItemand says when to emit it.
Design implications
Pushing state into one observable store buys you three things in a generative UI specifically:
- Persistence across turns. Surfaces come and go (
createSurface/deleteSurface), but DataModel paths outlive individual components. The AI can update a value without re-emitting the whole subtree. - Deterministic rebuilds. Since rebuilds key off path subscriptions, a chatty model stream doesn’t cause your entire surface to flicker — only the components whose data actually moved.
- Testability. You can unit-test your catalog builders against a populated
DataContextwithout ever spinning up an LLM.
The trade-off is that your widget code stops owning its state. Anything you stash in a StatefulWidget’s fields inside a catalog item will be invisible to the rest of the surface and to the model. Keep component-local state ephemeral (focus, animation controllers) and put everything the AI or other widgets might read into the DataModel.
Getting started
dart pub add genui
Then wire a SurfaceController, an A2uiTransportAdapter, and a Conversation as shown above, and check the examples repo — simple_chat shows a custom catalog, and the docs’ travel app example shows domain-specific generation. Note that genui is alpha: the docs mark the package as “experimental… likely to change,” so pin your version.