posit-dev / posit-dev/shinyreact
Value-equality dedup for reactive calcs and outputs
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 14
- Forks
- 3
- Avg merge
- 9h 12m
- Merged PRs (30d)
- 74
Description
Summary
In SPA-first apps, server state often looks like a single fat reactive dict that many derived calcs slice fields off of:
@reactive.calc
def app_state():
return {\"user\": ..., \"filters\": ..., \"selection\": ..., ...} # 50 fields
@reactive.calc
def selected_rows():
return app_state()[\"selection\"]
@reactive_output
def output_a():
return selected_rows()
When app_state() updates because filters changed:
selected_rows()invalidates and re-runs.- It returns the same value — selection didn't change.
output_are-emits anyway → identical bytes go across the wire.
This is wasted work in two places: the compute layer (downstream calcs re-running) and the wire layer (the renderer shipping unchanged data). At small scale it's invisible; at the data-state scale these apps will gravitate toward (rich state objects, many derived views), it adds up fast.
Proposed fix — two layers
Wire layer (in shinyreact's hands)
reactive_output should compare the new value to the last sent value and skip the send when they're equal. Owned entirely by us — no upstream changes required.
- Default equality:
==(Python deep equality for dicts/lists/scalars; works for the common cases). - Opt-out:
@reactive_output(check_equal=False)for the rare case where re-emitting matters (e.g. a value that doubles as an event signal — though those should generally be modeled as inputs, not outputs). - Custom equality:
@reactive_output(equal=lambda old, new: ...)for non-__eq__types (numpy arrays, pandas DataFrames, custom classes). - Memory cost: one extra reference to the previous value per output —
O(state size). Acceptable.
Compute layer (needs upstream or a wrapper)
@reactive.calc re-runs when invalidated regardless of whether the new return equals the old one, and unconditionally invalidates downstream. We want a calc that only invalidates downstream when its return value changes.
Three paths:
- Upstream change to
py-shiny. Add@reactive.calc(equal=...)(or a separate@reactive.memo) that re-runs but skips invalidation when equal. Cleanest, but requires coordination with the Shiny team. - shinyreact-side wrapper. Ship
@shinyreact.calc(equal=...)that internally pairs areactive.calc(recompute) with areactive.value(downstream signal), only.set()-ing the value when the recomputed value differs. Works today without upstream changes; downside is a parallel API surface for the same concept. - Document the workaround. Users hand-roll the pattern with
reactive.value+reactive.effect. Cheapest; pushes the cost onto every app author. Not a real answer.
Lean toward (2) as the v1 path with (1) as the long-term goal — file the upstream ask but don't block on it.
Open questions
- Equality semantics for big payloads. Comparing two 5MB DataFrames with
==is itself expensive. We may need a fast-path:isfirst (cheap), then==(deep), with the user able to short-circuit viaequal=for known-cheap signatures (length, hash of a known column). - Mutable values. If a calc returns a mutable dict and the user mutates it in place between flushes,
old == newisTrueeven though semantically the value changed. The reactive contract already discourages this, but a value-equality dedup makes the bug silent. Worth a docs note + maybe a runtime warning in dev mode. - Snapshot interaction. Bookmarking / state replay (#27) reads the current output values. If we suppress sends, the server's notion of "last sent" must be authoritative for snapshots — not a problem in practice but worth checking.
- Naming.
@shinyreact.calc(equal=...)vs@shinyreact.memovs piggybacking on the existing@reactive.calcwith a kwarg? Naming preview matters because (1) is the eventual goal — picking a name compatible with the eventual upstream API is a small but real call. - Default behavior change. Should
reactive_outputdedupe by default, or opt-in? Default-on saves bytes for everyone but changes observable timing (a render no longer fires on every flush). Lean default-on withcheck_equal=Falseopt-out, since SPA-first outputs are data, not events. - Compute-layer feature flag. Is the wrapper enough for v1, or do we want both wrapper and a docs path that users opt into? Probably wrapper only — too many ways to do it confuses scaffolding (#34).
Relationship to other issues
- Pairs with the JSON Patch issue (filed alongside this one) — dedup handles the no-change case, JSON Patch handles the partial-change case. Together they bound the wire footprint of fat-state apps.
- Cross-cuts #34 (scaffolding skill) — the skill should default to
@shinyreact.calcfor SPA-first apps if (2) lands, so wire-cost behavior is good out of the box. - Cross-cuts #31 (enhanced renderers) — round-trip inputs benefit from the same dedup story when the component re-emits unchanged state.
Out of scope
- R-side equivalents.
- Diffing the value to send only what changed — that's the JSON Patch issue.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by examining the reactive_output and reactive.calc entry points, then review the proposed wire-layer equality checks and shinyreact-side calc wrapper against the snapshot interaction in #27. Done means the implementation path, equality and opt-out semantics, mutable-value behavior, and upstream coordination are resolved and covered by appropriate validation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100