Skip to content

Narrowing rebuilds

This content is for the 0.1 version. Switch to the latest version for up-to-date documentation.

A chat transcript genuinely changes on every streamed token. Watching whole channel state therefore rebuilds the world per token, which is the difference between a UI that streams smoothly and one that does not.

Three layers keep it affordable, cheapest first.

SubscribeOptions.maxLatencyMs asks the host to batch envelopes before delivering. Chat and terminal channels default to 16 — roughly one frame.

This is the highest-leverage thing available and it needs no code. Without it the host delivers one envelope per token and every consumer pays, no matter how carefully the widget tree narrows afterwards.

AhpSelector rebuilds only when the value it selects changes:

AhpSelector<RootState, int>(
channel: root,
select: (state) => state.activeSessions ?? 0,
builder: (context, count, _) => Text('$count sessions'),
);

It compares by identity before equality. That check hits on every untouched sub-object — which the reducers are contracted to return unchanged — so the common case costs a pointer comparison.

A widget test pins the claim: two state changes that miss the selected slice produce zero rebuilds.

3. Narrow structure separately from content

Section titled “3. Narrow structure separately from content”

Selectors alone do not save a transcript, because the transcript really does change on every token. The fix is two levels.

Outside, select the id list. It is identity-stable across a token append, so this builder does not run per token — only when a message is added or removed:

AhpSelector<ChatState, List<String>>(
channel: chat,
select: (s) => s.messageIds,
builder: (context, ids, _) => ListView.builder(
reverse: true,
itemCount: ids.length,
itemBuilder: (c, i) => _MessageRow(key: ValueKey(ids[i]), id: ids[i]),
),
);

Inside, select the single message:

class _MessageRow extends StatelessWidget {
const _MessageRow({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context) => AhpSelector<ChatState, ChatMessage?>(
channel: chat,
select: (s) => s.messages[id],
builder: (context, message, _) =>
message == null ? const SizedBox.shrink() : MessageBubble(message),
);
}

A token appended to message 42 changes exactly one entry in messages, so exactly one selection sees a non-identical value and exactly one element rebuilds. Cost per token is a pointer comparison per mounted row plus one subtree rebuild — not one transcript rebuild.

Both levels hold leases on the same channel; refcounting collapses them to one subscription and one store.