[Guide] Build an Analytics App (Compute Heavy Work in Render) - implementation notes
The Analytics App guide has a bold posture: keep heavy compute in render so costs stay visible (and therefore negotiable), then use stored-derived aggregates where it stabilizes UI, and use remount keys to make identity boundaries explicit. It's a strong "make costs honest" philosophy—curious how people made it feel fast enough without turning it into memo superstition.
How did you decide what compute stays in render vs what becomes stored-derived aggregates? What memoization stance actually worked (memo nothing, memo selectors only, memo everything), and how did you keep it explainable? How did you use remount keys to avoid stale filters/selection under identity changes (project, date range)? What evidence/log lines were most useful to debug "why is this table slow" or "why did this aggregate change"?
Comments (24)
Back to latestCompute-in-render was scary until we treated it as a posture and paired it with explicit budgets.
If compute is visible and measured, teams can make tradeoffs intentionally instead of hiding work behind effects.
We logged compute passes as contract lines so perf discussions were grounded:
txt
[tips] renderCompute=aggregate:table rows=12000 cols=18 ms=42 posture=warm
[tips] derive=aggregates reason=filter:commit groups=12 ms=18
The important part is the vocabulary: compute vs derive. It prevents confusion about what's happening where.
Counterpoint: computing heavy work in render can turn a single slow dataset into a broken app.
We introduced a posture switch: compute-in-render for small/medium datasets, stored-derived for large, and we render the posture as evidence so it's not surprising.
Yes. The guide reads like an argument for *honest defaults*, not one rule for all cases.
If the posture is explicit and logged, you can evolve it as the dataset grows.
Memo stance that worked: memo derived selectors, keep components simple.
If you memo everything, you end up debugging memoization rather than analytics.
Long-form: the real win of compute-in-render is it forces a conversation about product truth.
If an aggregate takes 80ms, you either accept that cost (and design UX around it) or you change the model.
Hiding the cost behind async background work can make the UI feel fast, but it also makes correctness ambiguous (stale aggregates, partial updates).
Stored-derived aggregates were essential for coherence across routes (dashboard cards, table, export). We centralized derive and logged the inputs so changes were auditable:
txt
[tips] derive=aggregates reason=filter:commit range=30d segment=paidRemount keys saved us from stale selection bugs. We treat identity changes (projectId, range) as remount boundaries and we log them:
txt
[tips] remount boundary=project old=p1 new=p2 reason=nav:selectCounterpoint: stored-derived aggregates can drift if there are multiple mutation paths.
We required derive after every write and treated derive as idempotent, safe to run repeatedly.
Long-form counterpoint: compute-in-render can be used as an excuse to not optimize at all.
Visibility is good, but you still have to decide budgets and enforce them. Otherwise you're just staring at slow renders with no plan.
Docs tie-in: this is a perf posture story plus a core concepts story about making costs explicit.
Related: Performance and Rendering (Best Practices) and Core Concepts (Best Practices).
We rendered evidence: data-compute-posture, data-rows, and data-last-derive-at.
Support could tell whether a slow UI was due to dataset size, posture, or a stuck derive.
Long-form: analytics apps are where users notice inconsistency first.
If the card says 1200 users and the table says 1180, they will assume the product is unreliable.
Stored-derived aggregates + explicit derive logs are what kept our UI coherent across surfaces.
We also logged aggregate deltas rather than raw values to make changes reviewable:
txt
[tips] aggregates delta users:+20 sessions:-3 reason=filter:commitCounterpoint: heavy compute in render can make the UI unresponsive during interactions (INP).
We introduced a tick posture that defers compute while typing and logs the defer gate decisions.
We treated filters as multi-truth: draft in local state, commit into route doc on boundary (enter/blur/apply).
That prevented compute churn on every keystroke and made the commit boundary explicit for logs.
We used [tips] logs to build a "perf story" panel in dev.
It prints the last 20 compute/derive lines so you can see which action caused the slowdown.
If you implement this, start by deciding what you will measure and how you'll log it.
If compute is visible but not measured, it's just stress. Measured + logged makes it actionable.
Counterpoint: compute costs can be visible but still confusing if the app doesn't explain why it recomputed.
We logged the reason and inputs every time: datasetKey, filterKey, and posture.
We also used remount keys for table identity changes (columns change, segment change).
It avoided subtle bugs where old column renderers were still mounted and showing wrong formatting.
Long-form counterpoint: "compute in render" can be read as a vibe rather than a contract.
If you choose it, you should encode the posture in route state and render it. Otherwise you can't tell which mode you're in when debugging a slow report.
We kept export (CSV) based on the stored-derived aggregates so exports match UI exactly.
If export recomputes, you get "export doesn't match UI" bugs that are nearly impossible to convince users are harmless.
We also modeled compute failures as a lane (error) instead of crashing or silently showing stale values.
If compute fails, the UI should say it, and logs should explain why.
Counterpoint: sometimes the right fix is to compute less, not to compute differently.
We used the visibility posture to identify unnecessary aggregates and delete them. That ended up being the biggest perf win.
If you're implementing this, start by rendering evidence keys for dataset identity and compute posture.
Once those are visible, every perf and correctness conversation becomes much easier to ground.