DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A background refresh must not move the row under your cursor, so our table renders the snapshot it loaded with

Nakodo finds creators on YouTube, Instagram and TikTok for a brand, scores each one and emails the good fits. The part of the app this post is about is the boring part: the table of what it has found so far, which the owner of the campaign reads, sorts, filters and ticks boxes in while the pipeline behind it is still adding rows.

The page is a server component. A small client component polls an endpoint for a fingerprint of the campaign's data and calls router.refresh() when it changes:

export function AutoRefresh({ campaignId, every = 30_000 }: { campaignId: string; every?: number }) {
Enter fullscreen mode Exit fullscreen mode

Thirty seconds on the campaign's own pages, five on the outreach page while a draft is being written. Each quiet check doubles the wait, and the timer stops while the tab is hidden. (That endpoint and its fingerprint were their own post; this post is about what the refresh does to the page it lands on.)

router.refresh() is lovely. It re-renders the server component, keeps client state, and the new rows appear. It is also, in a sorted table, a small act of violence: the pointer is over row 14, a creator two rows up finishes being scored, the sort is by score, and row 14 is now a different creator. The checkbox you were about to tick belongs to someone else.

The list is not rows

The component takes rows as a prop and never renders it directly. On first render it captures a Map of the two fields the list sorts on:

type Snapshot = Map<string, { score: number | null; stage: string }>;

const snapshotOf = (rows: ResultRow[]): Snapshot =>
  new Map(rows.map((r) => [r.channelId, { score: r.score, stage: r.stage }]));
Enter fullscreen mode Exit fullscreen mode

Then the list it renders is built from rows and the snapshot together:

const { shown, changed } = useMemo(() => {
  const shown: ResultRow[] = [];
  let changed = 0;
  for (const r of rows) {
    const s = snapshot.get(r.channelId);
    if (!s) {
      changed++;
      continue;
    }
    const same = s.score === r.score && s.stage === r.stage;
    if (!same) changed++;
    shown.push(same ? r : { ...r, score: s.score, stage: s.stage });
  }
  return { shown, changed };
}, [rows, snapshot]);
Enter fullscreen mode Exit fullscreen mode

Three things fall out of that loop.

A row the snapshot has never seen is not rendered. It is counted.

A row whose score or stage has moved is rendered with the old score and stage. Every other field is the fresh one: a name, an avatar, a thread status, a view count. Only the two values the order depends on are held back, because they are the two that can move a row.

Everything else about the refresh still works. Counts in the header update, the "waiting on you" box updates, a creator whose conversation has advanced shows its new badge. The row simply stays where it is.

The banner is the whole feature

{changed > 0 && (
  <div role="status" className="...">
    <span>{plural(changed, w.one, w.many)} found or updated since this list loaded.</span>
    <Button
      size="xs"
      variant="outline"
      onClick={() => {
        setFresh(new Set(rows.filter((r) => !snapshot.has(r.channelId)).map((r) => r.channelId)));
        setSnapshot(snapshotOf(rows));
      }}
    >
      Show them
    </Button>
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

Two setState calls in that order. fresh is the set of ids that were not in the old snapshot, so it has to be computed before the snapshot is replaced. Re-snapshotting is what admits the new rows, because shown is derived from the snapshot.

The rows in fresh arrive with a highlight and a mark:

@keyframes row-arrive {
  0% { opacity: 0; background-color: var(--selected); box-shadow: inset 2px 0 0 var(--color-cyano-600); }
  12%,
  40% { opacity: 1; background-color: var(--selected); box-shadow: inset 2px 0 0 var(--color-cyano-600); }
}
@media (prefers-reduced-motion: no-preference) {
  .row-arrive {
    animation: row-arrive 2.4s ease-out calc(var(--i, 0) * 50ms) backwards;
  }
}
Enter fullscreen mode Exit fullscreen mode

--i is the row's index in the visible list, Math.min(i, 10), so twelve new rows stagger over half a second instead of over six. backwards is what makes the delay work: without it a row with a 500ms delay is fully visible for 500ms, then starts its own fade-in from nothing. The mark next to the name is labelled "New since this list loaded", so the highlight is not the only way to know.

The banner's role="status" costs one attribute and means the count is announced instead of appearing silently.

Two things that look like mistakes

A setState during render.

const [snapshot, setSnapshot] = useState(() => snapshotOf(rows));
if (snapshot.size === 0 && rows.length > 0) setSnapshot(snapshotOf(rows));
Enter fullscreen mode Exit fullscreen mode

That is React's own documented pattern for adjusting state when props change, not a mistake, and it is here because of a real sequence: a campaign's page opens seconds after it was created, when the first search has not returned anything, so rows is empty and so is the snapshot. The first batch to arrive would otherwise be twenty rows of "found since this list loaded" against an empty table, with a button to see them. An empty snapshot is not a snapshot, so it takes whatever arrives next. An effect would do the same thing one paint later, which is one paint of an empty table with a full banner over it.

useOptimistic over the derived list, not over the prop.

const [optimistic, setOptimistic] = useOptimistic(shown, (state, change: { ids: string[]; skipped: boolean }) => {
  const ids = new Set(change.ids);
  return state.map((r) => (ids.has(r.channelId) ? { ...r, skipped: change.skipped } : r));
});
Enter fullscreen mode Exit fullscreen mode

Setting a creator aside is a server action. The optimistic layer sits on top of shown, so while the action is in flight the row greys out immediately, and when the action settles and the page refreshes, the optimistic state is dropped onto a list that still has the same ordering. If useOptimistic wrapped rows, the row would grey out and then jump.

Paging works the same way, without an effect:

const [page, setPage] = useState({ key: "", limit: PAGE });
const limit = page.key === fromParam ? page.limit : PAGE;
Enter fullscreen mode Exit fullscreen mode

fromParam is the serialized filters. If they differ from the key the limit was raised under, the limit is PAGE again. Changing a filter resets paging to the first fifty by arithmetic rather than by a useEffect watching the filters.

What it costs

The table is deliberately stale, and the banner is the only signal. If you walk away with the tab open and come back, the list is as it was, with a count over it. We think that is the right trade for a table you work in with a pipeline running underneath, but it is a trade: there are two values for a creator's score in the client for as long as you leave the banner alone.

The other cost is that "changed" conflates two events: a new creator, and a creator whose score moved. One sentence covers both ("found or updated"), because splitting them meant a sentence with two numbers in it and nobody needs that in a notification bar.

Go and look

You cannot see this page without an account, and that is itself checkable:

$ curl -sI https://nakodo.app/campaigns | grep -iE "^HTTP|^location"
HTTP/2 307
location: /login?next=%2Fcampaigns
Enter fullscreen mode Exit fullscreen mode

What is public is the explanation of what the table is showing: how Nakodo works walks through the brief, the search and the parts of the fit score, which are the score and stage columns the snapshot is holding steady. The pricing page has the row that makes the count interesting, "Creators shown per campaign: Best 20 on Free, All on the paid plans", because the free plan's hidden rows are counted by the same predicate the table filters on.

If you have a table that re-renders from the server while somebody is using it, the pattern is four lines: snapshot the fields the order depends on, render from the snapshot, count the difference, and let the person decide when to take it.

Top comments (0)