posit-dev / posit-dev/shinyreact

Value-equality dedup for reactive calcs and outputs

Open
#35 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

theme: prototype
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:

  1. selected_rows() invalidates and re-runs.
  2. It returns the same value — selection didn't change.
  3. output_a re-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:

  1. 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.
  2. shinyreact-side wrapper. Ship @shinyreact.calc(equal=...) that internally pairs a reactive.calc (recompute) with a reactive.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.
  3. 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: is first (cheap), then == (deep), with the user able to short-circuit via equal= 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 == new is True even 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.memo vs piggybacking on the existing @reactive.calc with 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_output dedupe 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 with check_equal=False opt-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.calc for 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.