# Grouped Feed Without Flicker A chat feed with day separators and consecutive messages from one person grouped into a run. The shape matters more than the styling: written one way every arriving message repaints the whole board, and written another way only the new row is touched. ## The shape that repaints everything The obvious approach groups the rows first and loops twice, an outer loop over days and an inner loop over that day's messages: ```ts // Do not do this. function byDay(messages: LiveView): [string, Message[]][] { ... } ``` ```html
day}>

{day}

...
``` The outer key is stable, and it still repaints. Each group is a tuple holding a **fresh inner array** on every evaluation, so the row diff sees the same key with a different value and treats it as an update, and an update re-renders that group and everything nested inside it. One arriving message rebuilds every row of its day. With an enter animation on a row, that is a visible flicker across the board. ## The shape that patches Keep the loop flat. Compute one line per message, and carry the facts that depend on its neighbour as fields on that line: ```ts interface Line { id: string; message: Message; dayLabel: string; startsDay: boolean; startsRun: boolean; } function dayOf(m: Message): string { return m.createdAt.toDateString(); } function lines(messages: LiveView): Line[] { let out: Line[] = []; let prev: Message | undefined; for (let m of messages.sort((a, b) => +a.createdAt - +b.createdAt)) { let startsDay = prev === undefined || dayOf(prev) !== dayOf(m); out.push({ id: m.id, message: m, dayLabel: dayOf(m), startsDay, startsRun: startsDay || prev === undefined || prev.userName !== m.userName || +m.createdAt - +prev.createdAt > 5 * 60 * 1000, }); prev = m; } return out; } ``` ```html ``` The day separator and the author name become `e:if` on the row that starts them, so there is no second level to rebuild. `lines()` mints a fresh wrapper for every message on every change and the rows still patch: `e:key` pins each line to its message id, and a wrapper whose fields hold the same values is not a new value. Only a row whose facts actually moved re-renders, which is what you want when a message arrives and the row above it stops being the last of its run. Measured on a live insert: a browser holding four rows received a fifth and kept all four original elements. ## Rules of thumb - One `e:for` over one flat list. A loop inside a loop rebuilds the inner one whenever the outer row updates. - `e:key` takes a function: `e:key={(line: Line) => line.id}`. - Put a grouping decision on the row as a boolean, not in the structure. - A wrapper object per row is free. A fresh array per group is not. ## Related - `livetable/mutations`: inserting through a view, which is what broadcasts. - `recipes/chat-rooms`: the full chat app this feed belongs in. - `html`: `e:for`, `e:if` and `e:key`.