| Title: | Client-Side 'React' Interface for 'Shiny' |
| Version: | 0.1.0 |
| Description: | Server-side plumbing for the 'ui.tsx' pattern in 'Shiny': the user interface is defined in a client 'React' (https://react.dev/) bundle, and the 'Shiny' server contains only reactive computation. Provides page builders that discover and serve the client bundle, a render function that publishes any JSON-serializable value to the client, and custom messages to 'React' components. Ships no user interface components, so the app author owns the whole front end. The 'React' runtime and the client hooks are bundled, so no JavaScript build step is required to get started. |
| License: | MIT + file LICENSE |
| URL: | https://posit-dev.github.io/shinyreact/r/, https://github.com/posit-dev/shinyreact |
| BugReports: | https://github.com/posit-dev/shinyreact/issues |
| Imports: | brio, cli, htmltools, jsonlite, rlang, shiny (≥ 1.13.0), utils |
| Suggests: | knitr, later, rmarkdown, shinytest2, spelling, testthat (≥ 3.0.0), withr |
| VignetteBuilder: | knitr, rmarkdown |
| Config/needs/check: | spelling |
| Config/Needs/website: | pkgdown, tidyverse/tidytemplate |
| Config/roxygen2/version: | 8.1.0 |
| Config/testthat/edition: | 3 |
| Encoding: | UTF-8 |
| Language: | en-US |
| NeedsCompilation: | no |
| Packaged: | 2026-09-13 14:22:17 UTC; barret |
| Author: | Barret Schloerke |
| Maintainer: | Barret Schloerke <barret@posit.co> |
| Repository: | CRAN |
| Date/Publication: | 2026-09-23 04:20:02 UTC |
shinyreact: Client-Side 'React' Interface for 'Shiny'
Description
Server-side plumbing for the 'ui.tsx' pattern in 'Shiny': the user interface is defined in a client 'React' (https://react.dev/) bundle, and the 'Shiny' server contains only reactive computation. Provides page builders that discover and serve the client bundle, a render function that publishes any JSON-serializable value to the client, and custom messages to 'React' components. Ships no user interface components, so the app author owns the whole front end. The 'React' runtime and the client hooks are bundled, so no JavaScript build step is required to get started.
Author(s)
Maintainer: Barret Schloerke barret@posit.co (ORCID)
Authors:
Barret Schloerke barret@posit.co (ORCID)
Other contributors:
Winston Chang winston@posit.co (ORCID) [contributor]
Garrick Aden-Buie garrick@adenbuie.com (ORCID) [contributor]
Carson Sievert carson@posit.co (ORCID) [contributor]
Posit Software, PBC (ROR) [copyright holder, funder]
Meta Platforms, Inc. ('React' and 'ReactDOM', bundled in inst/lib/shiny/shinyreact.js) [copyright holder]
See Also
Useful links:
Report bugs at https://github.com/posit-dev/shinyreact/issues
As-is shinyreact input handler (internal)
Description
Opt-in via type = "shinyreact.asis". Returns the parsed value completely
untouched (no flattening), for nested structures the default would coerce.
Usage
asis_input_handler(value, session = NULL, name = NULL)
Default shinyreact input handler (internal)
Description
Applied to every untyped useShinyInput value. Undoes the parts of
jsonlite/shiny simplification that make R disagree with Python about the same
JSON payload. Python needs no such handler — its deserializer never
simplifies (see pkg-py/src/shinyreact/_input_handler.py).
Usage
default_input_handler(value, session = NULL, name = NULL)
Details
The contract, stated in terms of the JSON the React hook sent:
-
Array of objects (
[{a: 1}, {b: 2}]) — kept as a list of records This is the case Shiny's default handler gets wrong. -
Array of scalars (
[0, 100],["a", "b"]) — flattened to an atomic vector, exactly as shiny does by default. -
Empty array (
[]) —list(), matching Python's[]. Shiny's default yieldsNULL, conflating "empty" with "absent". -
Array of arrays (
[[1, 2], [3, 4]]) — nesting preserved. Shiny's default flattens it toc(1, 2, 3, 4), which destroys the shape the component sent. Anything else — returned as-is.
Init shinyreact input handler (internal)
Description
Registered in .onLoad() (zzz.R) for the shinyreact.init input type;
shiny invokes it when the JS bundle's single per-session
.shinyreact_init:shinyreact.init ping arrives (sent after Shiny
initializes – pkg-js/src/dep-discovery.ts). Its job is per-session
bootstrap: installing automatic output dependency discovery
(dep-discovery.R). The hook is installed before the ping's own reactive
flush runs, so that same flush performs the first dependency harvest –
server() has already registered its outputs by then. A dedicated handler
keeps the value-transforming handlers above pure, and the guaranteed ping
means every session bootstraps exactly once, with or without other inputs.
Usage
init_input_handler(value, session = NULL, name = NULL)
Bare HTML page with Shiny dependencies
Description
Escape hatch for custom setups. Wraps shiny::bootstrapPage().
Usage
page_bare(..., title = NULL, lang = "en")
Arguments
... |
Child tags or htmltools::htmlDependency objects. Named
arguments pass through to |
title |
Page title. |
lang |
HTML |
Details
With no theme, the page carries no Bootstrap: only jQuery, Shiny's own
JS/CSS, and a width=device-width viewport meta tag. Shiny's own default
would attach Bootstrap 3 (plus its accessibility plugin, which errors
against a newer jQuery), and in the ui.tsx pattern the client owns
styling. Pass a theme — e.g. theme = bslib::bs_theme(), or
bslib::bs_theme(version = 3) for the classic stack — to get Bootstrap
back; then ... is a plain passthrough to shiny::bootstrapPage().
Value
A shiny.tag page.
Examples
# No Bootstrap: just jQuery, Shiny, and the children you pass.
page_bare(htmltools::tags$div(id = "root"), title = "My app")
Create a React page from conventional assets — no HTML file required
Description
The zero-configuration page for the ui.tsx pattern: the server emits no
body HTML at all. Attaches the shinyreact bundle plus your app's entry
assets, discovered at www/ui.js and www/ui.css (relative to the working
directory — the app directory under shiny::runApp()). Your JS owns the
DOM: create and append your own mount container, e.g.
ReactDOM.createRoot(document.body.appendChild(document.createElement("div"))).
Usage
page_react(
...,
src_dir = "www",
js_file = "ui.js",
css_file = "ui.css",
title = NULL,
lang = "en",
shinyreact_js = "server"
)
Arguments
... |
Extra children or htmltools::htmlDependency objects. |
src_dir |
Directory containing the assets. Defaults to |
js_file |
JS entry filename within |
css_file |
CSS filename within |
title |
Page title. Defaults to the app folder's name ( |
lang |
HTML |
shinyreact_js |
Who supplies |
Details
ui.js is required (a missing file warns, pointing at the resolved path);
ui.css is attached only when it exists. Both are served as an
htmltools::htmlDependency versioned by ui.js's mtime, so the browser
re-fetches after every edit — unlike raw <script src=...> tags in a
hand-written HTML file, which the browser caches.
Value
UI suitable for shinyApp(ui = ...).
Examples
# In an app directory containing www/ui.js, `page_react()` with no
# arguments is the whole UI. Here the shipped hello example is pointed at
# explicitly:
www <- system.file("examples-shiny", "01-hello", "www", package = "shinyreact")
ui <- page_react(src_dir = www)
if (interactive()) {
shiny::runApp(system.file("examples-shiny", "01-hello", package = "shinyreact"))
}
HTML dependency for a downstream package's own JS/CSS bundle
Description
Convenience mirroring Python's page_react_dep(). It is versioned by the JS
file's mtime, so the /lib/{name}-{version}/ URL changes on every rebuild and
the browser re-fetches. That is what you want while developing and the wrong
thing for a published package — an mtime is whatever the install happened to
write, so it is neither stable across machines nor meaningful to a reader.
There is no version argument on purpose: a package shipping a fixed version
should build its own htmltools::htmlDependency (the same advice as for a
classic, non-module bundle), which is five lines and leaves nothing about the
dependency implicit.
Usage
page_react_dep(
src_dir,
js_file = "ui.js",
css_file = "ui.css",
name = basename(src_dir)
)
Arguments
src_dir |
Directory containing the JS/CSS. Required; Python infers this
from the calling module's |
js_file |
JS filename within |
css_file |
CSS filename within |
name |
Dependency name; defaults to |
Details
The script tag is emitted as type="module". A classic
<script defer> tag throws on the bundle's first import. type="module"
is implicitly deferred, so no defer attribute is needed. If your bundle is
a classic (non-module) script, build an htmltools::htmlDependency directly
instead of using this helper.
Both the script and the stylesheet are attached only when the file exists
inside src_dir, so a bundle that ships no CSS — or that has not been built
yet — does not emit a tag pointing at a 404. Pass css_file = NULL to never
attach a stylesheet. A missing js_file warns, since it is the entry point
and an empty dependency would otherwise fail silently.
A missing src_dir errors, where a missing js_file only warns. Shiny
serves the directory's files, so a directory that does not exist can only
produce 404s for every asset the page references — a bug every time, and one
that is far cheaper to see at page-build time than in the browser's network
tab. Matches Python's page_react_dep(), which raises NotADirectoryError.
Value
Examples
www <- system.file("examples-shiny", "01-hello", "www", package = "shinyreact")
page_react_dep(www)
Serve a React index.html document (the ui.tsx pattern)
Description
Reads a complete HTML document — the kind a Vite build emits — and injects
the shinyreact page-level dependencies into it. The document must contain
Shiny's dependency placeholder inside <head>:
Usage
page_react_html(
path = "www/index.html",
...,
extra_deps = NULL,
shinyreact_js = "server"
)
Arguments
path |
Path to the HTML document. Defaults to |
... |
Ignored. |
extra_deps |
A list of additional htmltools::htmlDependency objects to
render at the placeholder. A complete document has no tag tree to attach
dependencies to, so this is the only way in — the counterpart of
|
shinyreact_js |
Who supplies |
Details
<meta name="shiny-dependency-placeholder" content="">
Shiny's and shinyreact's script/link tags render in its place. It is an
ordinary <meta> tag rather than template syntax, so the document stays
valid HTML that a bundler's dev server can serve unchanged. Use as the ui
argument: shinyApp(ui = page_react_html(), server = ...).
Assets the document references (your bundle's JS/CSS) should live in www/,
where Shiny serves them statically.
For apps that don't need to own the HTML document, prefer page_react() —
it requires no HTML file at all.
Value
UI suitable for shinyApp(ui = ...).
The whole document is a template
R places the dependencies with htmltools::htmlTemplate(), which evaluates
every {{ ... }} in the document as R code — anywhere in it, <head> or
<body>, with the global environment as parent. So a body containing
{{ 6*7 }} renders 42, and {{ nonexistent() }} is an error at page
render.
A document written for a JS templating engine that also uses {{ }}
(Handlebars, Mustache, Vue's text interpolation) is therefore not safe to
pass here as is — those braces will be evaluated as R. Escape them, or use
page_react(), which needs no HTML file at all.
Python's page_react_html() differs: it replaces the placeholder and leaves
the rest of the document untouched. Documented as a deliberate divergence
rather than a bug — see FEATURES.md and issue #223.
Path resolution
A relative path resolves against the process working directory. Under
shiny::runApp() / shiny::shinyApp() that is the app directory, so the
default "www/index.html" just works. R has no per-caller __file__, so
unlike Python — which resolves a relative path against the calling module's
directory — there is nothing to resolve against outside the working
directory. Pass an absolute path if you need to be independent of it.
The placeholder must be spelled exactly as above — the check is a
fixed-string match, so a differently-quoted or reordered <meta> tag is
rejected.
Examples
index <- tempfile(fileext = ".html")
writeLines(
c(
"<!doctype html>",
"<html><head>",
'<meta name="shiny-dependency-placeholder" content="">',
'<script type="module" src="ui.js"></script>',
"</head><body></body></html>"
),
index
)
ui <- page_react_html(index)
unlink(index)
Publish a reactive value to a shinyreact client (the ui.tsx pattern)
Description
Server-side counterpart to useShinyOutputValue(). Assign to output[[id]];
a React client reads the value by id. There is no UI placeholder — the
client owns all UI. Accepts any JSON-serializable value (passed through
unchanged).
Usage
reactive_output(expr, env = parent.frame(), quoted = FALSE)
Arguments
expr |
An expression returning a JSON-serializable value. |
env |
The environment in which to evaluate |
quoted |
Is |
Value
A Shiny render function.
Examples
# The client reads this with useShinyOutputValue("greeting")
# and writes input$name with useShinyInput("name", "world").
server <- function(input, output, session) {
output$greeting <- reactive_output({
paste0("Hello, ", input$name, "!")
})
}
if (interactive()) {
shiny::shinyApp(page_react(), server)
}
Send a custom message to client React components
Description
Messages are consumed by useShinyMessageHandler(id, handler) on the
React side. The id is namespaced to the current Shiny module (if any)
so module-scoped handlers match, just like input/output ids.
Usage
send_message(session, id, data)
Arguments
session |
The Shiny session. |
id |
Message id; must match the |
data |
Any JSON-serializable data. |
Value
Invisibly NULL.
Examples
# Paired with useShinyMessageHandler("notify", (msg) => ...) on the client.
server <- function(input, output, session) {
shiny::observeEvent(input$save, {
send_message(session, "notify", list(text = "Saved", level = "success"))
})
}
if (interactive()) {
shiny::shinyApp(page_react(), server)
}
Tap the websocket wire of a shinytest2 app
Description
wire_tap() gives tests access to the JSON payloads that actually crossed
the Shiny websocket — the contract between the server and the React client.
Create the shinytest2::AppDriver with options = list(shiny.trace = TRUE)
so shinytest2 records every websocket frame in app$get_logs(); the tap
parses those frames into per-channel views.
Usage
wire_tap(app)
Arguments
app |
A shinytest2::AppDriver started with
|
Details
Cross-channel frame order (which output lands first, how outputs batch into
a single values frame, busy/progress interleaving) is reactive-scheduling
coincidence, not contract — so the tap deliberately does not expose a global
frame stream. Within one channel (one output id, one message type, one
input id) wire order is guaranteed, and the expect_* methods consume it
through a cursor: each expectation scans the recorded history from just
past the previous match, so values that arrive between checks are never
missed — capture is lossless; polling only decides when to re-scan.
Successive expectations on one channel therefore assert an ordered
subsequence.
The Python counterpart is shinyreact.playwright.WireTap, with the same
methods and semantics. One small divergence: jsonlite::fromJSON() maps a
JSON null output value to NULL, indistinguishable from an absent key,
so early output: null frames are dropped where Python sees None.
Value
A list of functions:
all_output_values(output_id)Every value the server delivered for
output_id, in order.all_messages(message_id)Every
send_message()payload ofmessage_id, in order.all_input_values(input_id)Every value the client sent for
input_id, in order. Matches the bare id or anyid:typewire id (e.g. the implicit:shinyreact.defaultsuffix), so use the id you wrote inuseShinyInput().expect_output_value(output_id, matcher, timeout = 10)Retrying expectation. A function
matcheris satisfied by a truthy return; any other object is compared withidentical(). Returns the matched value invisibly, or errors attimeout(seconds). A matcher that errors on a value's shape counts as a non-match.expect_message(message_id, matcher, timeout = 10)As above, for
send_message()payloads.expect_input_value(input_id, matcher, timeout = 10)As above, for client-sent input values.
Examples
app <- shinytest2::AppDriver$new(
"path/to/app",
options = list(shiny.trace = TRUE)
)
tap <- wire_tap(app)
# The JSON 30 parses to integer; identical() needs the right type.
tap$expect_input_value("bins", 30L)
tap$expect_output_value("dist_data", function(d) {
d$breaks[[1]] == 43 && sum(unlist(d$counts)) == 272
})