shinyreact implements the ui.tsx
pattern: the UI lives in a client-side React codebase, and the
Shiny server contains only reactive computation. The server never
renders HTML. It publishes data, and the client decides how to show
it.
The pieces:
-
page_react()bootstraps the page. It discoverswww/ui.js(andwww/ui.css) next toapp.Rand serves them along withshinyreact.js, which installs React and the hooks atwindow.shinyreact. -
reactive_output()publishes a JSON-serializable value under an output id. -
send_message()pushes a one-off message to the client. - On the client,
useShinyInput()sends values to the server,useShinyOutputValue()reads whatreactive_output()published, anduseShinyMessageHandler()receivessend_message()pushes.
A minimal app
The app directory holds app.R and a www/
folder:
my-app/
├── app.R
└── www/
├── ui.js
└── ui.css # optional
app.R:
library(shiny)
library(shinyreact)
ui <- page_react() # discovers www/ui.js + www/ui.css
server <- function(input, output, session) {
output$greeting <- reactive_output({
paste0("Hello, ", input$name, "!")
})
}
shinyApp(ui, server)www/ui.js, written without a build step. The bundle
exposes React and ReactDOM, so the client uses
React.createElement instead of JSX:
const { React, ReactDOM, useShinyInput, useShinyOutputValue } = window.shinyreact;
const h = React.createElement;
function App() {
const [name, setName] = useShinyInput("name", "world");
const greeting = useShinyOutputValue("greeting");
return h(
"div",
null,
h("input", { value: name, onChange: (e) => setName(e.target.value) }),
h("p", null, greeting)
);
}
// The page has no mount div; create one and append it to <body>.
const root = ReactDOM.createRoot(
document.body.appendChild(document.createElement("div"))
);
root.render(h(App));Run it with shiny::runApp("my-app"). Typing in the box
sends input$name to the server;
output$greeting recomputes and the paragraph updates.
Apps that want JSX, TypeScript, or npm packages compile
src/ui.tsx to www/ui.js with a bundler such as
Vite. page_react() does not care how the file was produced.
The examples
catalog shows each tier, from no-build to Vite + HMR.
Inputs
useShinyInput(id, default) registers a Shiny input and
returns [value, setValue], like
React.useState. Every setValue call is sent to
the server, where it arrives as input$id.
Until the client’s first value arrives, input$id is
NULL. Guard for that (or use req()) in outputs
that depend on it:
output$dist <- reactive_output({
n <- input$bins
if (is.null(n)) {
return(NULL)
}
hist(faithful$waiting, breaks = n, plot = FALSE)$counts
})Values arrive as the JSON the client sent, with two conveniences:
arrays of scalars become atomic vectors (c(0, 100)), and
[] stays list() rather than becoming
NULL. Pass { type: "shinyreact.asis" } to
useShinyInput() to receive the parsed JSON untouched, or
any other Shiny input-handler name (such as
"shiny.datetime") to route the value through that
handler.
For action buttons, start at 0 and increment on click,
with the debounce disabled so no click is coalesced:
Outputs
reactive_output() is assigned to output$id.
Whatever the expression returns is serialized with jsonlite and
delivered to useShinyOutputValue("id") on the client,
unchanged. Return lists for structured data. Use I() to
keep a length-one vector as a JSON array:
output$dist_data <- reactive_output({
h <- hist(faithful$waiting, breaks = input$bins, plot = FALSE)
list(breaks = I(h$breaks), counts = I(h$counts))
})The client can also observe an output’s lifecycle with
useShinyOutputStatus("id"), which is "pending"
before the first value, "recalculating" while the server
recomputes, and "ready" otherwise. Keep the previous value
mounted while recalculating; only show a placeholder when no value has
ever arrived.
Messages
send_message() pushes a payload the client handles once,
outside the reactive output graph:
observeEvent(input$save, {
send_message(session, "notify", list(text = "Saved", level = "info"))
})Traditional Shiny renderers
Render functions from other packages still work. Assign them to
output$id as usual and render them on the client with the
ShinyOutput component, which binds the output element
inside your React tree:
output$plot <- plotly::renderPlotly({
plotly::plot_ly(x = ~ faithful$waiting, type = "histogram")
})const { ShinyOutput } = window.shinyreact;
h(ShinyOutput, { id: "plot", className: "plotly html-widget html-widget-output" });No plotlyOutput() placeholder is needed. shinyreact
discovers the renderer’s JavaScript and CSS dependencies from the render
function and delivers them to the client automatically.
Bookmarking
Pass enableBookmarking = "url" (or
"server") to shinyApp() as usual. Restored
input values are embedded in the page, and useShinyInput()
uses them as initial values instead of its default.
Next steps
-
TSX
files and JavaScript build tools explains
.tsx, JSX, TypeScript, and whatnpm run builddoes, for readers new to JavaScript tooling. -
Testing
shows how to assert the JSON a server produces with
shiny::testServer(), and the JSON that crosses the websocket withwire_tap(). - The JS
reference documents every hook and component at
window.shinyreact. -
DESIGN.mdexplains why the pattern looks the way it does.