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.
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.
Audit log
Audit log
Audit log
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
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.
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.
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.
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.
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).
What the user sees = what they export.
Final composition
~140 lines. Filter chips, day buckets, expandable diff, CSV export. Pull-to-refresh is variant A.
Audit log
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-pressedon 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.