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.
1. Let the host coalesce
Section titled “1. Let the host coalesce”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.
2. Select a slice
Section titled “2. Select a slice”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.