← Home

Case study

Daybook

A business-banking dashboard in React with a 100,000-row ledger, no backend, and a live payment feed that re-renders only the row each payment settles.

  • React
  • TypeScript
  • Web Workers
  • Canvas
Visit the live app View source on GitHub

One row per payment

Misconception: A React dashboard this busy is re-rendering constantly.

Daybook is a business-banking dashboard for a fictional small company: three accounts, a ledger of 100,000 transactions, a cash-flow chart with a forecast, budgets, team cards and a live feed of incoming and outgoing payments.

There is no backend: the app is static files, the data is generated in the browser and in a worker, and the only runtime dependencies are react and react-dom.

The live feed is the stress test: a seeded schedule of 60 payments arrives while you work, and each one settles a transaction that is already in the ledger rather than inserting a new one.

An insertion at the top would shift every index below it and re-render the whole window, while a settlement changes one row key, which is what makes “one row per payment” measurable at all.

Each table row subscribes to its own key in the feed store through useSyncExternalStore, so a settlement notifies the one row whose status changed, and the totals re-query the worker without touching the rows.

In the default view that is one row; with the running-balance column shown, a declined payment also re-renders the rows whose balance it changes, and the test asserts that count exactly.

When the worker answers with the same ids and new totals, the query hook keeps the previous ids reference, so the table never sees a new prop.

The claim is a test rather than a profiler screenshot: src/pages/transactions.test.tsx wraps every row and the ledger in a <Profiler> and counts commits.

act(() => {
  deliverForTest(settled)
})

expect(profile.rows).toBe(1)
expect(profile.commits.ledger).toBe(1)
expect(profile.commits.sidebar).toBe(0)

A payment outside the visible window commits zero rows and zero ledger renders, and still updates the totals.

Manual memoisation is banned by ESLint and the React Compiler does it instead; an unminified build shows the app shell compiled with an 11-slot memo cache.

The compiler still needed measuring: passing filter.sort.column straight into the JSX made the compiler cache that element against the whole filter object, costing one wasted commit per keystroke, and destructuring the primitives first fixed it.

A hundred thousand rows, thirty-five in the DOM

Misconception: A 100,000-row ledger needs a backend and pagination.

The ledger worker builds typed-array columns over all 100,000 transactions once and answers every filter, search and sort with ids only, plus the totals.

The answer is an exactly sized Int32Array whose buffer is transferred rather than copied, and the main thread renders each visible row from transaction(id), a pure, constant-time function of the index.

No row data crosses the worker boundary, there is no page-window protocol to keep in step with the scroll position, and a result is one allocation and one transfer however far the user scrolls.

That only works because the world is deterministic: transaction(i) seeds its own PRNG stream from the world seed and the index, so the main thread and the worker generate the same row independently.

A Vitest test generates the world twice and pins its fingerprint, and Math.random, Date.now and argument-less new Date() are banned outside the feed clock and the performance HUD by ESLint and a source test, so the ledger cannot drift between loads.

The table windows its rows against the page scroller: at 1440 x 950 it holds 35 rows mid-scroll and 29 at the end, while aria-rowcount reports the full set.

Roving focus is keyed by row, so it survives recycling and re-sorting, and Page Up and Page Down move by the visible row count read from the live layout at key time, so Page Up undoes a Page Down.

Filter state lives in the URL alone, so Back steps through filter states and leaves a search in one step.

The longest interaction while filtering and searching measured 16 to 24 ms across five local runs on a 4x CPU throttle, against a 50 ms budget that CI asserts on every push.

Popovers without a positioning library

Misconception: Menus and tooltips need a positioning library.

Menu and Tooltip are built on the Popover API and CSS anchor positioning, with no positioning code at all.

Each instance derives a dashed ident from useId(), writes anchor-name on the trigger and position-anchor on the panel through ref callbacks, and the CSS Module places the panel with position-area and flips it with position-try-fallbacks.

The menu is a popover="auto" panel opened by a popovertarget button, so Escape and light dismiss come from the platform rather than from event handlers.

Menus, tooltips and dialogs all render in the top layer, so the design tokens carry no popover or dialog z-index to keep in order.

The chart tooltip is the honest edge case: script writes the active bar’s box into custom properties on the plot, and CSS anchor positioning places the tooltip beside it, flipping to the left near the right edge and then above, so it never covers the bar it describes.

Script places the anchor box over the bar; it never computes where the tooltip goes.

The cost is a browser floor of Chromium 129, Safari 26 and Firefox 147, with no production polyfill.

Undo after commit

Misconception: Optimistic UI is a trade against correctness.

Category edits, card freezes and budget edits apply at once and commit after a simulated 400 ms latency, each with an Undo toast.

The pending actions live in an external store whose merged view every reader shares, with useOptimistic only at the detail page’s Select, so the ledger rows, the worker’s category overlay and the detail page all see the same pending edit.

Each row selects its own id from that merged view through useSyncExternalStore, so a pending categorisation re-renders only the rows whose category changes.

Undo is where optimistic interfaces usually cut corners.

Undoing a pending action cancels its timer and writes nothing; undoing a committed one restores the previous value only while the stored value is still what the action set, so a change made in another tab in the meantime is left alone unless it wrote the very value the action set.

When a later action was layered on the undone one, it inherits the undone action’s previous value, so undoing out of order still lands on the right state.

A property test runs 150 seeded sequences of 40 steps across all three kinds of edit, with undos of pending and committed actions in any order and writes from outside the layer, and after every step checks the stored edits and the merged view against a replayed model.

An Undo toast stays for 10 seconds rather than the usual 6, so a keyboard user can reach its button from the control that raised it, and focus or hover inside the region pauses it.

Ledger ink

The design language is a ruled book of first entry: a paper surface, ink text, horizontal rules instead of boxes, tabular figures, and a bottle green accent that reads as ink rather than fintech.

Tokens are semantic roles with light-dark() values, so one declaration carries both themes, and prefers-contrast: more and forced-colors: active adjust the same tokens.

The component library in src/ui/ is plain CSS Modules with no style props and no !important; dynamic values go through data attributes or custom properties written through a ref.

The /design page is its documentation, and it measures instead of asserting: a hidden probe resolves every token in the browser, so light-dark() and clamp() are settled by the engine rather than copied from a document.

It measures 30 contrast pairs at runtime in the current theme, cuts each ratio to two decimals so a shown value never flatters the measurement, and measures again when the theme changes.

A unit test guards the same pairs from the token source, so a failing pair fails CI before it reaches the page.

Measuring caught a real defect: the original strong border came in as low as 2.32 to 1 against the surfaces, under the 3 to 1 minimum for non-text contrast, so the token moved further from the surfaces in both themes, darker in light and lighter in dark, rather than the rule being relaxed.

Verified end to end

The bundle budget, the dependency count and the single inline script are build-time checks: scripts/verify-bundle.mjs fails on any runtime dependency besides react and react-dom, on initial JavaScript over budget, or unless dist/index.html holds exactly one inline script whose hash the Content Security Policy pins.

Unit tests cover the world generator, the worker’s index and queries, the optimistic layer and the components, with Profiler counts behind every re-render claim and a URL codec property test that round-trips 500 seeded states.

The Playwright suite runs against the built app under the real Content Security Policy, and 77 end-to-end tests passed on the CI runner, covering axe, keyboard paths, zero policy violations, the DOM row count and layout shift.

Lighthouse CI asserts 100 in all four categories and zero layout shift on /, /transactions and /design on every push.

The result

The shipped app depends on react and react-dom alone, loads 86 KB of gzipped JavaScript before first paint against an 87 KB budget, and scores 100 in every Lighthouse category on the desktop preset with a layout shift of zero.

On Lighthouse’s mobile settings, performance measures 97 on two routes and 94 on the design page, because nothing paints until the initial JavaScript has loaded on simulated slow 4G.

Each headline claim lives where it can be checked: the re-render count in a Profiler test, popover placement in the browser’s own anchor positioning, and the undo rule in a property test against a replayed model.