posit-dev / posit-dev/shinyreact
Enhanced renderers: expose round-trip inputs and update methods on the renderer
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 14
- Forks
- 3
- Avg merge
- 9h 12m
- Merged PRs (30d)
- 74
Description
Summary
Today a @reactive_output (or any future @render_plotly, @render_table_react, etc.) is a one-way pipe: Python returns a value, the React component renders it. But many useful components also send signals back — selected rows, brushed regions, zoom extents, expanded nodes — and accept commands — "reset zoom", "select row 4", "update plot limits".
Today users would wire those by hand: pick useShinyInput names in JSX, declare matching inputs.<name> in Python, send custom_messages via session.send_custom_message for the command direction. Each downstream package re-invents the contract.
We should let the renderer be that contract. Inputs the component emits become fields on the renderer; commands the component accepts become methods on the renderer. The renderer already owns the output ID and has the session in scope, so it has everything it needs.
Precedent
@render.data_frame in Shiny for Python already does this:
@render.data_frame
def grid():
return render.DataGrid(df, selection_mode=\"rows\")
@reactive.effect
def _():
rows = grid.cell_selection() # round-trip input, exposed as method on the renderer
grid.update_cell_value(...) # command, exposed as method on the renderer
We want the same ergonomics for reactive_output and any enhanced renderers downstream packages add (render_plotly, render_echart, render_ag_grid, …).
Proposed shape
@render_plotly
def my_plot():
return px.scatter(df, x=\"x\", y=\"y\")
@reactive.effect
def _():
pts: SelectedPoints | None = my_plot.selected_points() # round-trip input
extent: PlotExtent | None = my_plot.zoom_extent() # round-trip input
@reactive.effect
@reactive.event(input.reset)
def _():
my_plot.reset_zoom() # command → custom_message
my_plot.set_limits(x=(0, 10), y=(0, 5)) # command → custom_message
Mechanics:
- Round-trip inputs are reactive values registered under derived names (e.g.
f\"{id}__selected_points\"). They're exposed as callables on the renderer so the call site looks like a normal Shiny input read but doesn't need a separateinputs.<name>declaration. - Commands are methods on the renderer that wrap
session.send_custom_message(or the shinyreactsend_messageequivalent) and target the renderer's own ID. The user never names the message type. - Discovery — round-trip inputs and commands are declared on the renderer subclass once (by the package author). Users don't re-declare them per app.
Why this is the right home
- The renderer already captures
id+sessionat decoration time. - Naming collisions are bounded by the output ID — no global input-name design needed.
- Downstream packages (
shinyplotly,shinyshadcn, etc.) get a clear extension point: subclass the renderer, declare round-trip inputs and commands once. - Pairs naturally with #30 (typed Inputs/Outputs codegen): the renderer's declared round-trips become typed attributes automatically.
Sketch of the base class API
class EnhancedRenderer(Renderer[T]):
# Declarative — package authors fill these in
round_trip_inputs: dict[str, type] = {} # name → type
commands: dict[str, type] = {} # name → payload type
def __getattr__(self, name):
# round_trip_inputs[name] → reactive read
# commands[name] → bound method that calls send_message
...
Or, more explicitly, code-generate the subclass from a declaration so attribute access is statically typed (ties into #30).
Open questions
- Naming: are the JS-side input names a fixed convention (
{id}__selected_points) or configurable per renderer? Fixed is simpler and lets the React component look them up by the parent's ID; configurable adds flexibility nobody may need. - Lifecycle: how do round-trip inputs behave when the component unmounts / the output is hidden? Likely "last value sticks until next render" — same as Shiny inputs today.
- Error surface for commands: command sent before the component mounts — silently queue, or warn? Shiny's existing
update_*family silently no-ops; matching that is probably right. - Async commands (where the JS side acks): out of scope for v1; renderer methods are fire-and-forget like today's
update_*. - Typing story: exposing
selected_points: Callable[[], SelectedPoints | None]requires either codegen (#30) or aGeneric[Inputs, Commands]base class with__class_getitem__magic. Codegen is the more honest answer. - Discoverability: do we want a registry so devtools can list "every output and its round-trip inputs / commands" for debugging?
- Does this subsume
send_message? Once commands live on the renderer, the baresend_message(session, type, data)API is for cases without a corresponding output — worth keeping but de-emphasized.
Out of scope
- R package equivalent.
- Two-way binding semantics (where the component owns canonical state and Python is the secondary). Renderers stay output-driven; round-trip inputs are observations of component state, not authoritative state.
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
No implementation files or tests are named. Start by locating Renderer, reactive_output, and the existing render.data_frame implementation, then resolve the open API questions for declared round-trip inputs and commands. Done means an agreed, documented renderer extension point with tests covering reactive reads and command delivery.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, react, typescript
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100