Home/ Cookbook/ Lists/ History feed

Build a history feed — actor, action, diff, time

By the end of this recipe you'll have an audit timeline: who did what, when, and what changed — with filter chips, expandable diffs, CSV export, and pull-to-refresh.

Lists 3 primitives · 2 blocks · 0 patterns ~25 min React · @corelithzw/react

Overview

A read-only timeline of immutable events: avatar, actor name, verb phrase, target, timestamp. Tap an event to expand a before/after diff. Filter by actor, action, or date range.

Every operator app needs an audit log. Customers ask "who changed this?" and the answer has to be uncontested — not "probably Tendai." History gives you that defense, but only if it's read-only. The moment a row is editable, the timeline is no longer evidence.

History is read-only — never edit in place. An editable history breaks the trust contract: if rows can be rewritten, they can't testify. Correct mistakes by appending new events, not by mutating old ones.

Skip this recipe if what you actually need is a recent-activity widget — that's the simple list recipe. Skip if you need to mutate (then it's a transaction list, not history). History feeds are for legal/compliance, not UX nostalgia.

What you'll build

Default timeline, expanded diff, filter chips.

Huchu

Audit log

Today
TM
Tendai approved Leave #438
09:42
RZ
Rufaro changed price on SKU-12
09:14
Yesterday
CM
Chido deleted Tax rule #3
18:04
01 Default — actor, action, target
Huchu

Audit log

RZ
Rufaro changed price on SKU-12
price: $8.90$9.40
09:14
CM
Chido deleted Tax rule
18:04
02 Expanded — before/after diff
Huchu

Audit log

Tendai × price.changed × + filter
TM
Tendai changed price on SKU-44
Mon 10:20
TM
Tendai changed price on SKU-12
last week
03 Filtered — chips active

Required pieces

Avatar, row, filter chips, a button. Date-bucket the rows.

@corelithzw/react exports used here: Avatar, RowCard, FilterChips, Button, Spinner. Reuse the bucketing function from the lists-grouped-by-date recipe.

Step-by-step build

01

Model the event — immutable, with a typed action

An event has an actor, a verb (the action key), a target, an ISO timestamp, and an optional changes object for the diff. The action is an enum string — never a free-form sentence, because filters need to match on it.

Code-only step

Action is a union, not a string — every new action type is a compile error somewhere until you wire the label. That's the point.

Step 1 · Event shape@corelithzw/react
02

Render one row — verb phrase, not template string in JSX

Build the sentence in a helper, not inline in the row. A single function maps (action, targetLabel) to the human verb phrase — this is the only place to change copy when product changes their mind about wording.

TM
Tendai approved Leave #438
09:42
Step 2 · EventRow
03

The diff panel — before vs after, no smart truncation

For each changed field, show field: before → after. Don't truncate before/after values — if an audit row truncates the change, it's not an audit row. If a field is too long, line-wrap. The user came here for the truth.

price
$8.90$9.40
Step 3 · Diff panel
04

Filter + export — chips drive the predicate, predicate drives CSV

One filter state, one predicate, one filtered array. The CSV export writes the same filtered array — so what the user exports matches what they see. Anything else is a footgun ("I exported but only got 3 rows" — yes, your filter was still on).

CSV escapes quotes the boring way.
What the user sees = what they export.
Step 4 · Filter + CSV

Final composition

~140 lines. Filter chips, day buckets, expandable diff, CSV export. Pull-to-refresh is variant A.

Audit log

Tendai × + Action
Today
TM
Tendai approved Leave #438
09:42
HistoryFeed.tsx@corelithzw/react

Variations

Three forks. Same read-only spirit.

Pull-to-refresh on mobile

A drag handler on the scroll container — if the user pulls 60px past the top, fire onRefresh. Show a spinner in the overscroll area. Don't try to replicate the iOS feel exactly; close enough is fine.

const onTouchMove = (e) => {
  if (scroll.scrollTop > 0) return;
  const dy = e.touches[0].clientY - startY;
  if (dy > 60 && !refreshing) onRefresh();
};

Single-object timeline

When viewing one record, show only its history. Drop the actor filter (probably noise on a single item), keep the action filter, and indent the diff so the field column lines up.

<HistoryFeed
  events={events.filter(
    e => e.objectId === id
  )}
  // hide actor chips entirely
/>

Compact mode — no avatars

For dense audit views (compliance review), drop avatars and use a fixed-width actor column. Three lines fit where one used to — useful when scanning hundreds of events.

<li className="evt-compact">
  <span className="actor">
    {e.actor.name}
  </span>
  <span>{verbPhrase(…)}</span>
  <time>{e.at}</time>
</li>

Accessibility

Audit logs are evidence — accessibility is non-negotiable.

  • Expand toggle is a real button with aria-expanded. Not a div with a click handler — assistive tech needs to know the row is interactive.
  • <time dateTime> on every timestamp. Machine-readable ISO plus human-readable text; screen readers read both correctly.
  • Diff uses <dl> <dt> <dd>. A description list is semantically the right shape for field → value-change pairs.
  • Filter chips announce their state. Use aria-pressed on chip buttons so screen readers say "Tendai, pressed" when active.
  • Empty filter result speaks. "No events match these filters." in a role="status" region — silent empty states are confusing.
  • CSV download is a button, not a link. A button triggers a blob download; a link to # with a JS handler is a focus trap.