Skip to main content

shinyreact

React UI infrastructure for Shiny — Python and R

With shinyreact, the Shiny server (Python or R) contains only reactive computation, and the UI is a React client you own. shinyreact is the bridge between the two; it ships zero UI components itself.

One JavaScript bundle backs both languages, so the same React client works identically against an app.py or an app.R server.

The ui.tsx pattern

  1. The app file bootstraps a static page — set_react_page() (Python Express) or page_react() (Python Core, R).
  2. Your React client, conventionally ui.tsx (compiled to www/ui.js, discovered automatically), renders the UI.
  3. Client and server talk through hooks: useShinyInput sends values to the server, useShinyOutputValue receives what reactive_output publishes, and useShinyMessageHandler receives send_message() pushes.
from shiny.express import input
from shinyreact import reactive_output, set_react_page

set_react_page()


@reactive_output
def greeting():
    return {"message": f"Hello, {input.name()}"}
library(shiny)
library(shinyreact)

server <- function(input, output, session) {
  output$greeting <- reactive_output({
    list(message = paste0("Hello, ", input$name))
  })
}

shinyApp(page_react(), server)
const { React, ReactDOM, useShinyInput, useShinyOutputValue } = window.shinyreact;

function App() {
  const [name, setName] = useShinyInput("name", "world");
  const greeting = useShinyOutputValue("greeting");
  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>{greeting?.message}</p>
    </>
  );
}

// No mount div in the generated page — append one to <body>.
ReactDOM.createRoot(document.body.appendChild(document.createElement("div"))).render(<App />);

The power of reactive_output

In traditional Shiny applications, the server owns the markup: renderPlot(), renderUI(), renderReactable() each return a description of UI that the browser swaps into a placeholder. reactive_output returns nonregulated, raw data. To the Shiny server, it is an ordinary Shiny output in every other way, so the reactive graph, req(), reactive.event, caching, and modules all work unchanged, but what travels the wire is a JSON value, and the client decides what to do with it.

That one change in approach buys several advantages at once:

  • One output, many components. A single payload can feed a chart, a table, and a caption. There is no *Output() placeholder per widget, no per-widget binding JS, and no coupling between the server’s return type and a specific renderer.
  • The server sends facts, the client does presentation. Sorting, formatting, hover state, tab selection, and “show more” toggles happen in React with no round trip. The server recomputes only when the data actually changes.
  • No DOM tear-down. React reconciles the new value into the mounted tree. Pair it with useShinyOutputStatus to dim stale data while the server recomputes instead of flashing a skeleton.

One reactive_output driving three pieces of UI:

from shiny import reactive
from shiny.express import input
from shinyreact import reactive_output, set_react_page

set_react_page()

sales = load_sales()  # long-format fact table: date, region, revenue


@reactive.calc
def filtered():
    return sales[sales.region.isin(input.regions())]


@reactive_output
def summary():
    df = filtered()
    by_month = df.groupby(df.date.dt.to_period("M")).revenue.sum()
    return {
        "n": int(len(df)),
        "total": float(df.revenue.sum()),
        "series": [{"month": str(m), "revenue": float(v)} for m, v in by_month.items()],
        "rows": df.head(50).to_dict(orient="records"),
    }
library(shiny)
library(shinyreact)

sales <- load_sales() # long-format fact table: date, region, revenue

server <- function(input, output, session) {
  filtered <- reactive({
    sales[sales$region %in% input$regions, ]
  })

  output$summary <- reactive_output({
    df <- filtered()
    by_month <- aggregate(revenue ~ format(date, "%Y-%m"), df, sum)
    names(by_month) <- c("month", "revenue")
    list(
      n = nrow(df),
      total = sum(df$revenue),
      series = by_month,
      rows = head(df, 50)
    )
  })
}

shinyApp(page_react(), server)
const { useShinyInput, useShinyOutputValue, useShinyOutputStatus } = window.shinyreact;

function Dashboard() {
  const [regions, setRegions] = useShinyInput("regions", ["East", "West"]);
  const summary = useShinyOutputValue("summary");
  const status = useShinyOutputStatus("summary");
  const [sortKey, setSortKey] = React.useState("date"); // client-only state, no round trip

  if (!summary) return <Skeleton />; // only before the first value arrives

  const rows = [...summary.rows].sort((a, b) => (a[sortKey] > b[sortKey] ? 1 : -1));

  return (
    <div className={status === "recalculating" ? "stale" : ""}>
      <RegionPicker value={regions} onChange={setRegions} />
      <p>
        {summary.n.toLocaleString()} sales, {formatCurrency(summary.total)}
      </p>
      <RevenueChart data={summary.series} />
      <SalesTable rows={rows} sortKey={sortKey} onSort={setSortKey} />
    </div>
  );
}

The server never learns that a chart exists. Swap the chart library, add a third view of the same data, or move the table sort to the server later, and the other side does not change.

Installation

shinyreact is pre-release. Install from GitHub:

pip install "git+https://github.com/posit-dev/shinyreact.git"
pak::pak("posit-dev/shinyreact/pkg-r")

The JS hooks come with the server package — set_react_page() / page_react() serve shinyreact.js, which installs them at window.shinyreact. Apps with a bundler can instead npm install @posit-dev/shinyreact and pass shinyreact_js="client" to the page entry point.

Agent Skills

Both packages ship two Agent Skills so a coding agent can build a shinyreact app without you pasting documentation into it: shinyreact-build-app builds a ui.tsx-pattern app from scratch, and shinyreact-convert-app ports an existing Shiny app to the pattern.

The fastest starter template is the skill itself. With the shinyreact skills installed, paste this into your agent (swap in R if that is your server):

Using the `/shinyreact-build-app` skill, scaffold a new shinyreact app in this
directory with a Python server. Set up the Vite build (src/ui.tsx compiled to
www/ui.js) and a hello-world UI: a text input whose value the server greets by
name. Run the build and start the app.

Reference

Python set_react_page(), page_react(), ReactApp, reactive_output, send_message(), …
R page_react(), reactive_output(), send_message(), …
JS useShinyInput, useShinyOutputValue, useShinyMessageHandler, ShinyOutput, …
Examples Runnable apps, from no-build www/ui.js to Vite + HMR
Design Why the pattern looks the way it does