diff --git a/content/blog/introducing-shinyreact/_gif-src/.gitignore b/content/blog/introducing-shinyreact/_gif-src/.gitignore new file mode 100644 index 000000000..329b429d3 --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/.gitignore @@ -0,0 +1,2 @@ +plotomics-live.mp4 +__pycache__/ diff --git a/content/blog/introducing-shinyreact/_gif-src/capture.py b/content/blog/introducing-shinyreact/_gif-src/capture.py new file mode 100644 index 000000000..e22092b31 --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/capture.py @@ -0,0 +1,136 @@ +"""Regenerate the post's inline media. + +- hello-app.gif: the shinyreact `examples/01-hello` Old Faithful app + (copied into ./hello-app), recorded frame-by-frame while the bin-count + slider rests, then moves through a few values. +- plotomics-live.mp4: re-encoded from the screen recording used in the + posit::conf(2026) talk (downloaded on demand, cached next to this file). + +Usage (requires ffmpeg on PATH): + + uv run --with playwright --with shinyreact --with "shiny>=1.8" python capture.py + +Outputs are written to the post directory (the parent of this folder). +""" + +import pathlib +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request + +from playwright.sync_api import sync_playwright + +HERE = pathlib.Path(__file__).parent +POST_DIR = HERE.parent +APP = HERE / "hello-app" / "app.py" + +WIDTH, HEIGHT = 960, 540 # 16:9 +FPS = 10 +# (target bin count, seconds to slide there, seconds to rest afterwards) +MOVES = [(30, 0.0, 2.0), (8, 0.5, 1.5), (45, 0.7, 1.5), (20, 0.5, 1.5), (30, 0.4, 2.0)] + +PLOTOMICS_MP4 = "https://schloerke.com/presentation-2026-09-15-posit-conf-shinyreact/images/plotomics-live.mp4" +PLOTOMICS_CACHE = HERE / "plotomics-live.mp4" + +PALETTE = "split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4" + +# React only sees a range input change when the native value setter runs and +# an `input` event bubbles; `page.fill()` won't do it for type=range. +SET_BINS = """(v) => { + const el = document.querySelector('#bins'); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, String(v)); + el.dispatchEvent(new Event('input', { bubbles: true })); +}""" + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_for(url: str) -> None: + for _ in range(60): + try: + urllib.request.urlopen(url, timeout=1) + return + except OSError: + time.sleep(0.5) + raise SystemExit("app did not start") + + +def to_gif(frames_glob: str, out: pathlib.Path, extra: list[str] = ()) -> None: + subprocess.run( + ["ffmpeg", "-y", "-framerate", str(FPS), "-i", frames_glob, *extra, + "-vf", PALETTE, "-loop", "0", str(out)], + check=True, + capture_output=True, + ) + print(f"{out.name}: {out.stat().st_size / 1024:.0f} KB") + + +def record_hello() -> None: + port = free_port() + url = f"http://127.0.0.1:{port}/" + app = subprocess.Popen( + [sys.executable, "-m", "shiny", "run", "--port", str(port), str(APP)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + wait_for(url) + with sync_playwright() as p, tempfile.TemporaryDirectory() as tmp: + frames = pathlib.Path(tmp) + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) + page.goto(url) + page.wait_for_selector("svg rect") # first histogram rendered + page.wait_for_timeout(500) + + n = 0 + current = 30 + for target, slide_s, rest_s in MOVES: + steps = int(slide_s * FPS) + for i in range(1, steps + 1): + page.evaluate(SET_BINS, round(current + (target - current) * i / steps)) + page.wait_for_timeout(1000 // FPS) + page.screenshot(path=str(frames / f"frame_{n:03d}.png")) + n += 1 + current = target + for _ in range(int(rest_s * FPS)): + page.wait_for_timeout(1000 // FPS) + page.screenshot(path=str(frames / f"frame_{n:03d}.png")) + n += 1 + print(f"bins={target}: {n} frames so far", end="\r") + browser.close() + print() + to_gif(str(frames / "frame_%03d.png"), POST_DIR / "hello-app.gif") + finally: + app.terminate() + app.wait() + + +def convert_plotomics() -> None: + # Stays an mp4: a million-point scatter defeats GIF palettes (16 MB at + # 960px vs 2.7 MB h264), and the site already plays local mp4 in posts. + if not PLOTOMICS_CACHE.exists(): + print("downloading plotomics-live.mp4...") + urllib.request.urlretrieve(PLOTOMICS_MP4, PLOTOMICS_CACHE) + out = POST_DIR / "plotomics-live.mp4" + subprocess.run( + ["ffmpeg", "-y", "-i", str(PLOTOMICS_CACHE), "-an", + "-vf", f"scale={WIDTH}:-2", "-c:v", "libx264", "-crf", "26", + "-preset", "slow", "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(out)], + check=True, + capture_output=True, + ) + print(f"{out.name}: {out.stat().st_size / 1024:.0f} KB") + + +if __name__ == "__main__": + record_hello() + convert_plotomics() diff --git a/content/blog/introducing-shinyreact/_gif-src/hello-app/app.py b/content/blog/introducing-shinyreact/_gif-src/hello-app/app.py new file mode 100644 index 000000000..9a5293edb --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/hello-app/app.py @@ -0,0 +1,18 @@ +from faithful import histogram, waiting +from shiny.express import input +from shinyreact import reactive_output, set_react_page + +set_react_page() + + +# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a +# `dict` return is not assignable to it. Drop the ignore when that lands. +@reactive_output # pyright: ignore[reportArgumentType] +def dist_data(): + return histogram(waiting, input.bins()) + + +@reactive_output +def dist_caption(): + n = input.bins() + return f"{len(waiting)} eruptions in {n} bin{'' if n == 1 else 's'}" diff --git a/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.csv b/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.csv new file mode 100644 index 000000000..2f23dc97a --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.csv @@ -0,0 +1,273 @@ +"eruptions","waiting" +3.6,79 +1.8,54 +3.333,74 +2.283,62 +4.533,85 +2.883,55 +4.7,88 +3.6,85 +1.95,51 +4.35,85 +1.833,54 +3.917,84 +4.2,78 +1.75,47 +4.7,83 +2.167,52 +1.75,62 +4.8,84 +1.6,52 +4.25,79 +1.8,51 +1.75,47 +3.45,78 +3.067,69 +4.533,74 +3.6,83 +1.967,55 +4.083,76 +3.85,78 +4.433,79 +4.3,73 +4.467,77 +3.367,66 +4.033,80 +3.833,74 +2.017,52 +1.867,48 +4.833,80 +1.833,59 +4.783,90 +4.35,80 +1.883,58 +4.567,84 +1.75,58 +4.533,73 +3.317,83 +3.833,64 +2.1,53 +4.633,82 +2,59 +4.8,75 +4.716,90 +1.833,54 +4.833,80 +1.733,54 +4.883,83 +3.717,71 +1.667,64 +4.567,77 +4.317,81 +2.233,59 +4.5,84 +1.75,48 +4.8,82 +1.817,60 +4.4,92 +4.167,78 +4.7,78 +2.067,65 +4.7,73 +4.033,82 +1.967,56 +4.5,79 +4,71 +1.983,62 +5.067,76 +2.017,60 +4.567,78 +3.883,76 +3.6,83 +4.133,75 +4.333,82 +4.1,70 +2.633,65 +4.067,73 +4.933,88 +3.95,76 +4.517,80 +2.167,48 +4,86 +2.2,60 +4.333,90 +1.867,50 +4.817,78 +1.833,63 +4.3,72 +4.667,84 +3.75,75 +1.867,51 +4.9,82 +2.483,62 +4.367,88 +2.1,49 +4.5,83 +4.05,81 +1.867,47 +4.7,84 +1.783,52 +4.85,86 +3.683,81 +4.733,75 +2.3,59 +4.9,89 +4.417,79 +1.7,59 +4.633,81 +2.317,50 +4.6,85 +1.817,59 +4.417,87 +2.617,53 +4.067,69 +4.25,77 +1.967,56 +4.6,88 +3.767,81 +1.917,45 +4.5,82 +2.267,55 +4.65,90 +1.867,45 +4.167,83 +2.8,56 +4.333,89 +1.833,46 +4.383,82 +1.883,51 +4.933,86 +2.033,53 +3.733,79 +4.233,81 +2.233,60 +4.533,82 +4.817,77 +4.333,76 +1.983,59 +4.633,80 +2.017,49 +5.1,96 +1.8,53 +5.033,77 +4,77 +2.4,65 +4.6,81 +3.567,71 +4,70 +4.5,81 +4.083,93 +1.8,53 +3.967,89 +2.2,45 +4.15,86 +2,58 +3.833,78 +3.5,66 +4.583,76 +2.367,63 +5,88 +1.933,52 +4.617,93 +1.917,49 +2.083,57 +4.583,77 +3.333,68 +4.167,81 +4.333,81 +4.5,73 +2.417,50 +4,85 +4.167,74 +1.883,55 +4.583,77 +4.25,83 +3.767,83 +2.033,51 +4.433,78 +4.083,84 +1.833,46 +4.417,83 +2.183,55 +4.8,81 +1.833,57 +4.8,76 +4.1,84 +3.966,77 +4.233,81 +3.5,87 +4.366,77 +2.25,51 +4.667,78 +2.1,60 +4.35,82 +4.133,91 +1.867,53 +4.6,78 +1.783,46 +4.367,77 +3.85,84 +1.933,49 +4.5,83 +2.383,71 +4.7,80 +1.867,49 +3.833,75 +3.417,64 +4.233,76 +2.4,53 +4.8,94 +2,55 +4.15,76 +1.867,50 +4.267,82 +1.75,54 +4.483,75 +4,78 +4.117,79 +4.083,78 +4.267,78 +3.917,70 +4.55,79 +4.083,70 +2.417,54 +4.183,86 +2.217,50 +4.45,90 +1.883,54 +1.85,54 +4.283,77 +3.95,79 +2.333,64 +4.15,75 +2.35,47 +4.933,86 +2.9,63 +4.583,85 +3.833,82 +2.083,57 +4.367,82 +2.133,67 +4.35,74 +2.2,54 +4.45,83 +3.567,73 +4.5,73 +4.15,88 +3.817,80 +3.917,71 +4.45,83 +2,56 +4.283,79 +4.767,78 +4.533,84 +1.85,58 +4.25,83 +1.983,43 +2.25,60 +4.75,75 +4.117,81 +2.15,46 +4.417,90 +1.817,46 +4.467,74 diff --git a/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.py b/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.py new file mode 100644 index 000000000..6d0dd6500 --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/hello-app/faithful.py @@ -0,0 +1,34 @@ +"""Old Faithful waiting times + a dependency-free histogram binner. + +Shared by `app.py` (Express) and `app-core.py` (Core). R's `app.R` uses the +`faithful` dataset built into base R and `hist(..., plot = FALSE)` instead; +`faithful.csv` is that same dataset exported for the Python servers. +""" + +from __future__ import annotations + +import csv +import math +from pathlib import Path + +_CSV = Path(__file__).parent / "faithful.csv" + +with _CSV.open(newline="") as f: + waiting: list[float] = [float(row["waiting"]) for row in csv.DictReader(f)] + + +def histogram(values: list[float], bins: int) -> dict[str, list[float] | list[int]]: + """Equal-width binning matching R's `hist()`: bins are (lo, hi], first inclusive.""" + lo, hi = min(values), max(values) + width = (hi - lo) / bins + breaks = [lo + i * width for i in range(bins + 1)] + counts = [0] * bins + for v in values: + # ceil, so a value sitting exactly on a break belongs to the bin BELOW + # it — that is what makes the interval (lo, hi]. Clamping to bin 0 + # gives the first bin its inclusive `lo`. Truncating instead ([lo, hi)) + # agrees with R on the Old Faithful data only because no waiting time + # lands on an interior break. + idx = math.ceil((v - lo) / width) - 1 + counts[min(max(idx, 0), bins - 1)] += 1 + return {"breaks": breaks, "counts": counts} diff --git a/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.css b/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.css new file mode 100644 index 000000000..7a6a0aa4a --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.css @@ -0,0 +1,70 @@ +body { + margin: 0; + padding: 0; + background: #fff; + font-family: system-ui, sans-serif; + color: #1a1a1a; +} + +.layout { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + max-width: 60rem; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.sidebar { + flex: 1 1 12rem; + align-self: start; + padding: 1rem; + background: #f5f5f5; + border-radius: 8px; +} + +.sidebar label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.sidebar input[type="range"] { + width: 100%; +} + +.bins-value { + display: block; + margin-top: 0.25rem; + font-variant-numeric: tabular-nums; + color: #666; +} + +.panel { + flex: 3 1 24rem; +} + +.panel h1 { + margin: 0; + font-size: 1.5rem; +} + +.caption { + margin: 0.25rem 0 1rem; + color: #666; + font-size: 0.9rem; +} + +.placeholder { + display: grid; + place-items: center; + height: 20rem; + border-radius: 8px; + background: #f5f5f5; + color: #888; +} + +.recalculating { + opacity: 0.6; + transition: opacity 200ms; +} diff --git a/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.js b/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.js new file mode 100644 index 000000000..65c170d96 --- /dev/null +++ b/content/blog/introducing-shinyreact/_gif-src/hello-app/www/ui.js @@ -0,0 +1,191 @@ +const { + React, + ReactDOM, + useShinyInput, + useShinyOutputValue, + useShinyOutputStatus, + useShinyInitialized, +} = window.shinyreact; + +const h = React.createElement; + +// --- histogram chart ------------------------------------------------------- + +const W = 620; +const H = 380; +const M = { top: 16, right: 16, bottom: 48, left: 56 }; +const PLOT_W = W - M.left - M.right; +const PLOT_H = H - M.top - M.bottom; + +// Round `max` up to a friendly axis top, using a 1/2/5 × 10^n tick step. +function yTicks(max) { + const raw = Math.max(max, 1) / 4; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw); + const top = Math.ceil(max / step) * step; + const ticks = []; + for (let v = 0; v <= top; v += step) ticks.push(v); + return { top, ticks }; +} + +function xTicks(lo, hi) { + const step = 10; + const ticks = []; + for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) ticks.push(v); + return ticks; +} + +function Histogram({ data }) { + const { breaks, counts } = data; + const lo = breaks[0]; + const hi = breaks[breaks.length - 1]; + const { top, ticks } = yTicks(Math.max(...counts)); + + const x = (v) => M.left + ((v - lo) / (hi - lo)) * PLOT_W; + const y = (v) => M.top + PLOT_H - (v / top) * PLOT_H; + + return h( + "svg", + { + viewBox: `0 0 ${W} ${H}`, + width: "100%", + role: "img", + "aria-label": `Histogram of Old Faithful waiting times in ${counts.length} bins`, + }, + // y gridlines + labels + ticks.map((t) => + h( + "g", + { key: `y${t}` }, + h("line", { + x1: M.left, + x2: M.left + PLOT_W, + y1: y(t), + y2: y(t), + stroke: "#e5e5e5", + }), + h( + "text", + { + x: M.left - 10, + y: y(t), + textAnchor: "end", + dominantBaseline: "middle", + fontSize: 12, + fill: "#666", + }, + t, + ), + ), + ), + // bars + counts.map((count, i) => { + const x0 = x(breaks[i]); + const x1 = x(breaks[i + 1]); + return h("rect", { + key: i, + x: x0, + width: Math.max(x1 - x0 - 1, 1), + y: y(count), + height: M.top + PLOT_H - y(count), + fill: "#447099", + }); + }), + // x axis + h("line", { + x1: M.left, + x2: M.left + PLOT_W, + y1: M.top + PLOT_H, + y2: M.top + PLOT_H, + stroke: "#888", + }), + xTicks(lo, hi).map((t) => + h( + "text", + { + key: `x${t}`, + x: x(t), + y: M.top + PLOT_H + 20, + textAnchor: "middle", + fontSize: 12, + fill: "#666", + }, + t, + ), + ), + h( + "text", + { + x: M.left + PLOT_W / 2, + y: H - 8, + textAnchor: "middle", + fontSize: 13, + fill: "#333", + }, + "Waiting time to next eruption (minutes)", + ), + h( + "text", + { + transform: `translate(16 ${M.top + PLOT_H / 2}) rotate(-90)`, + textAnchor: "middle", + fontSize: 13, + fill: "#333", + }, + "Frequency", + ), + ); +} + +// --- app ------------------------------------------------------------------- + +function App() { + const initialized = useShinyInitialized(); + const [bins, setBins] = useShinyInput("bins", 30); + const data = useShinyOutputValue("dist_data", null); + const caption = useShinyOutputValue("dist_caption", null); + const status = useShinyOutputStatus("dist_data"); + + if (!initialized) return null; + + return h( + "main", + { className: "layout" }, + h( + "aside", + { className: "sidebar" }, + h("label", { htmlFor: "bins" }, "Number of bins:"), + h("input", { + id: "bins", + type: "range", + min: 1, + max: 50, + value: bins, + onChange: (e) => setBins(Number(e.target.value)), + }), + h("output", { htmlFor: "bins", className: "bins-value" }, bins), + ), + h( + "section", + { className: "panel" }, + h("h1", null, "Hello Shiny!"), + h("p", { className: "caption" }, caption ?? " "), + // Keep the chart mounted while the server recomputes — only show the + // placeholder before the first value has ever arrived. + data + ? h( + "div", + { className: status === "recalculating" ? "recalculating" : "" }, + h(Histogram, { data }), + ) + : h("div", { className: "placeholder" }, "Loading…"), + ), + ); +} + +// No mount div in the generated page — create the container and append it to . +// The script is deferred, so document.body is parsed by the time this runs. +const root = ReactDOM.createRoot( + document.body.appendChild(document.createElement("div")), +); +root.render(h(App)); diff --git a/content/blog/introducing-shinyreact/feature.png b/content/blog/introducing-shinyreact/feature.png new file mode 100644 index 000000000..caaa64089 Binary files /dev/null and b/content/blog/introducing-shinyreact/feature.png differ diff --git a/content/blog/introducing-shinyreact/hello-app.gif b/content/blog/introducing-shinyreact/hello-app.gif new file mode 100644 index 000000000..c2367c864 Binary files /dev/null and b/content/blog/introducing-shinyreact/hello-app.gif differ diff --git a/content/blog/introducing-shinyreact/hex-build.mp4 b/content/blog/introducing-shinyreact/hex-build.mp4 new file mode 100644 index 000000000..b9b42e63b Binary files /dev/null and b/content/blog/introducing-shinyreact/hex-build.mp4 differ diff --git a/content/blog/introducing-shinyreact/index.md b/content/blog/introducing-shinyreact/index.md new file mode 100644 index 000000000..b23eb4ce5 --- /dev/null +++ b/content/blog/introducing-shinyreact/index.md @@ -0,0 +1,257 @@ +--- +title: 'Introducing shinyreact: React UI backed by a Shiny server' +date: 2026-09-30T00:00:00.000Z +people: + - Barret Schloerke +description: > + shinyreact is a new R and Python package that keeps Shiny in charge of + reactive computation and hands the entire UI to a React client you own. Two + hooks, one JSON contract, and Agent Skills that write the React for you. +image-video: hex-build.mp4 +image: feature.png +image-alt: >- + The shinyreact hex logo: the Shiny hex sticker and the React atom slide + together, and the atom settles into the tail of the Shiny swoosh. +topics: + - Interactive Apps + - Artificial Intelligence +software: + - shinyreact + - shiny-r + - shiny-python +languages: + - R + - Python +events: + - posit-conf-2026 +source: shiny +execute: + eval: false +--- + + +We're excited to introduce [shinyreact](https://posit-dev.github.io/shinyreact/), a new package for R and Python. It lets you write the UI of a Shiny app in React, with any component library on npm, while reducing your Shiny server code to data-only logic. + +shinyreact splits a Shiny app along a clean line. The Shiny server does reactive computation. The UI is a [React](https://react.dev) client that you own. shinyreact is the bridge between them, and it ships zero UI components of its own. + +You can install it from CRAN or PyPI: + +
+ +
+ +``` r +install.packages("shinyreact") +``` + +
+
+ +``` bash +pip install shinyreact +``` + +
+
+ +shinyreact is new, and so is the way of building Shiny apps it proposes. We intend to keep the API small, but expect it to evolve as we learn from early adopters. + +## Shiny + React? + +For most Shiny apps, defining the UI in your app.py or app.R file allows you to construct a complete, production-ready app in just a few lines of code. + +The trouble starts when the design asks for something Shiny and [bslib](https://rstudio.github.io/bslib/) don't have: a unique layout, richer interaction, or a component from a non-Bootstrap design system. At that point, Shiny hasn't had the right tool for the job. + +React is that tool, for three reasons: + +- **Ecosystem.** React is the most widely used UI library on the web. Design systems, charts, tables, and maps are all one `npm install` away. +- **The right model.** React components are functions of state, which fits Shiny's reactive model naturally. When the server sends new data, the UI re-renders efficiently. +- **AI assistance.** LLMs have trained on an enormous amount of React code. Ask a frontier agent for a UI and it will produce better React than it will bespoke Shiny UI. This aligns with Shiny's goal that app developers should never be required to write low-level HTML or JavaScript themselves. + +Later in the post, we'll discuss a genomics app that renders a 584,000-cell UMAP on the GPU from a plain Shiny server. First, the basics. + +## Old Faithful with shinyreact + +Here is the classic Old Faithful histogram app as a shinyreact app: + +
+ +
+ +``` r +library(shiny) +library(shinyreact) + +# Set up the page UI using shinyreact +ui <- page_react() + +server <- function(input, output, session) { + x <- faithful$waiting + + breaks <- reactive({ + seq(min(x), max(x), length.out = input$bin_count + 1) + }) + + # Use `reactive_output()` to send data to the client + output$dist_data <- reactive_output({ + bins <- hist(x, breaks = breaks(), plot = FALSE) + list(breaks = I(bins$breaks), counts = I(bins$counts)) + }) +} + +shinyApp(ui, server) +``` + +
+
+ +``` python +import numpy as np +import pandas as pd +from shiny.express import input +from shinyreact import reactive_output, set_react_page + +# Set up the page UI using shinyreact +set_react_page() + +x = pd.read_csv("faithful.csv")["waiting"].to_numpy() + + +@reactive_output +def dist_data(): + breaks = np.linspace(x.min(), x.max(), input.bin_count() + 1) + counts, _ = np.histogram(x, bins=breaks) + return {"breaks": breaks.tolist(), "counts": counts.tolist()} +``` + +
+
+ +Two things are different from a traditional Shiny app. + +1. **The UI is one line.** `page_react()` (or `set_react_page()` in Shiny Express) serves the React client that lives in your app's `www/` directory. There's no `sliderInput()` or `plotOutput()`. The `www/` directory contains the static assets for the React app: `ui.js` and `ui.css` (when available). +2. **The output is data.** `reactive_output()` has no matching UI function. This is a new concept for the Shiny ecosystem! `renderPlot()` sends an image for `plotOutput()` to place. `reactive_output()` sends plain JSON, here the histogram's `breaks` and `counts`. Like any render function, `reactive_output()` re-executes each time its reactive dependencies change. The server sends facts and React decides how to present them. + +Now the client: + +``` tsx +// src/ui.tsx (which compiles to www/ui.js) +function App() { + const [binCount, setBinCount] = useShinyInput("bin_count", 30); + const bins = useShinyOutputValue("dist_data", null); + + return ( +
+ + setBinCount(Number(e.target.value))} + /> + +
+ ); +} +``` + +
+The Old Faithful app: dragging the bin-count slider re-renders the histogram as the server sends new breaks and counts. + +
+ +If you've written React before, this is an ordinary component. `Histogram` is whatever you like: a hand-written SVG, a charting library, or a component from your design system. The only shinyreact-specific parts are two hooks: + +- `useShinyInput()` works like React's `useState()`, except that the value is also sent to the server as `input$bin_count` (or `input.bin_count()` in Python). Calling `setBinCount()` updates the UI and triggers the server's reactive graph. +- `useShinyOutputValue()` reads the value of a `reactive_output()`. When the server recomputes `dist_data`, the component re-renders with the new data. + +Those two hooks cover the vast majority of apps. + +## IDs and JSON are the contract + +The client and server share exactly two things: IDs and JSON values. If you write the client in TypeScript, each hook takes an optional type for its value, such as `useShinyInput("bin_count", 30)`, and your editor will then flag a mismatch that `Shiny.setInputValue()` never could. + +Here is the full round trip for the Old Faithful app: + +1. The client calls `useShinyInput("bin_count", 30)`, which sends `{"bin_count": 30}` to the server. +2. The server reads `input$bin_count`, runs its reactive graph, and computes `dist_data` using `reactive_output()`. +3. The client receives the `dist_data` result as `{"dist_data": {"breaks": [...], "counts": [...]}}`, and `useShinyOutputValue("dist_data")` hands it to React. + +That narrow boundary is what makes a shinyreact app easy to reason about. The server doesn't know or care how the histogram is drawn, and the client doesn't know how the bins are computed. Each side can be reviewed, tested, and rewritten independently, whether a person or an agent wrote it. + +When you need more, a few other hooks are available. `useShinyOutputStatus()` tells you when an output is recalculating so you can show a skeleton, and `useShinyMessageHandler()` receives one-off messages pushed from the server with `send_message()`. See the [JavaScript API reference](https://posit-dev.github.io/shinyreact/js/) for the full list. + +## You don't have to write the React yourself + +A fair reaction to all of this is, "but I chose Shiny so I wouldn't have to write JavaScript!". That's still the goal. What's changed is that today's AI agents are very good at writing React, far better than they are at writing custom Shiny UI, because there is so much more React in the world for them to learn from. + +With shinyreact, your job is to own the server, which is where your data and domain logic live, and to describe and review the UI. To make that concrete, both packages ship [Agent Skills](https://posit-dev.github.io/shinyreact/articles/agent-skills.html): + +- **`shinyreact-build-app`** scaffolds a new shinyreact app from a description. +- **`shinyreact-convert-app`** opens an existing Shiny app in a browser, describes what it does in plain English, and then rewrites the UI in React against the same server. + +The day-to-day loop is familiar. You edit `src/ui.tsx` (or ask an agent to), the build step writes `www/ui.js`, and you reload the running Shiny app to see the change. The server side is unchanged: `runApp()` or `shiny run`, exactly as before. + +Node.js isn't required, since a client can be a single `www/ui.js` file with no build step. However, we do strongly recommend it as it gives you a proper development environment: a build step gets you TypeScript, linting, and formatting. + +## Keep what you already have + +shinyreact doesn't ask you to throw away the rest of the Shiny ecosystem. + +- **Existing outputs.** `renderPlotly()`, `render.data_frame`, and other render functions work as before. Drop a `` into your React tree, and the output's JavaScript and CSS dependencies are delivered automatically. +- **Modules.** `ShinyModuleProvider` namespaces hook IDs to match a server-side module. +- **Bookmarking.** URL and server bookmarking seed the initial values of `useShinyInput()`. + +## Testing at every layer + +Because the client and server only share IDs and JSON, each layer can be tested on its own. The server is the layer most Shiny developers care about, and it needs no browser at all. Use `shiny::testServer()` in R, or the new `local_server` pytest fixture in [Shiny for Python 1.8](../../blog/2026-09-22_shiny-python-1-8/): set inputs and assert on the JSON that comes out. + +``` python +def test_histogram(local_server): + local_server.set_inputs(bin_count=10) + data = local_server.get_output("dist_data") + assert len(data["counts"]) == 10 +``` + +The other layers have their own tools: + +- **The client.** Ordinary JavaScript unit tests, with whichever test runner you (or your agent) prefer. +- **The wire.** `wire_tap()` for [shinytest2](https://rstudio.github.io/shinytest2/) and `WireTap` for Playwright record the JSON crossing the websocket during a browser test. That gives you an end-to-end assertion on what the client actually sent and what the server actually returned, without reaching into the rendered DOM. +- **The behavior.** Each example app ships a `FEATURES.md`: a nested list where every leaf is one checkable claim about the app, written in plain English. A person can read it as a spec, and an agent with a browser can walk it and turn each claim into a deterministic check against the running app. + +The [testing article](https://posit-dev.github.io/shinyreact/articles/testing.html) covers all of them. + +## In the wild: Plotomics Live + +Over the summer, Shiny intern [Samuel Bharti](https://www.samuelbharti.com) built a [collection of bioinformatics Shiny apps](https://posit-shiny-showcase-bioinformatics.share.connect.posit.cloud/). Most of them are plain Shiny and bslib. The one that reached for shinyreact did so because the visualizations demanded it. + +[Plotomics Live](https://posit-plotomics-live.share.connect.posit.cloud/) ([source](https://github.com/samuelbharti/plotomics-live), [DOI](https://doi.org/10.5281/zenodo.21936926)) is a 26-page gallery of GPU-accelerated genomics visualizations, from oncoplots to a one-million-point Xenium spatial view and an interactive 584,000-cell UMAP. Large data skips JSON entirely and moves as compact binary typed arrays straight to the GPU. Because React owns the component, a new selection updates the data in place without re-mounting the visualization or reallocating GPU buffers. + + + +In Samuel's words: + +> R stays the analysis engine, React becomes the visualization layer, and shinyreact removes the custom JavaScript bindings, manual message passing, and serialization code that used to sit between them. + +## What's next + +We're working on two directions next: + +- **Embedding React components in existing apps**, so you can adopt shinyreact one piece at a time without porting a whole app. +- **Wrapping shinyreact in your own package**, so you can build a component once and ship it the way bslib ships its components. + +## Learn more + +- Documentation: [posit-dev.github.io/shinyreact](https://posit-dev.github.io/shinyreact/) +- Source and example apps: [github.com/posit-dev/shinyreact](https://github.com/posit-dev/shinyreact) +- posit::conf(2026) talk slides: [Beyond Bootstrap: Building Custom Shiny UI with React](https://schloerke.com/presentation-2026-09-15-posit-conf-shinyreact/) + +Give shinyreact a try, and please [let us know](https://github.com/posit-dev/shinyreact/issues) what you build and what breaks. diff --git a/content/blog/introducing-shinyreact/index.qmd b/content/blog/introducing-shinyreact/index.qmd new file mode 100644 index 000000000..300a6d0f2 --- /dev/null +++ b/content/blog/introducing-shinyreact/index.qmd @@ -0,0 +1,241 @@ +--- +title: "Introducing shinyreact: React UI backed by a Shiny server" +date: 2026-09-30 +people: + - Barret Schloerke +description: > + shinyreact is a new R and Python package that keeps Shiny in charge of + reactive computation and hands the entire UI to a React client you own. Two + hooks, one JSON contract, and Agent Skills that write the React for you. +image-video: "hex-build.mp4" +image: "feature.png" +image-alt: >- + The shinyreact hex logo: the Shiny hex sticker and the React atom slide + together, and the atom settles into the tail of the Shiny swoosh. +topics: + - Interactive Apps + - Artificial Intelligence +software: + - shinyreact + - shiny-r + - shiny-python +languages: + - R + - Python +events: + - posit-conf-2026 +source: shiny +execute: + eval: false +--- + +We're excited to introduce [shinyreact](https://posit-dev.github.io/shinyreact/), a new package for R and Python. It lets you write the UI of a Shiny app in React, with any component library on npm, while reducing your Shiny server code to data-only logic. + +shinyreact splits a Shiny app along a clean line. The Shiny server does reactive computation. The UI is a [React](https://react.dev) client that you own. shinyreact is the bridge between them, and it ships zero UI components of its own. + +You can install it from CRAN or PyPI: + +::: {.panel-tabset group="language"} +## R + +```r +install.packages("shinyreact") +``` + +## Python + +```bash +pip install shinyreact +``` +::: + +shinyreact is new, and so is the way of building Shiny apps it proposes. We intend to keep the API small, but expect it to evolve as we learn from early adopters. + +## Shiny + React? + +For most Shiny apps, defining the UI in your app.py or app.R file allows you to construct a complete, production-ready app in just a few lines of code. + +The trouble starts when the design asks for something Shiny and [bslib](https://rstudio.github.io/bslib/) don't have: a unique layout, richer interaction, or a component from a non-Bootstrap design system. At that point, Shiny hasn't had the right tool for the job. + +React is that tool, for three reasons: + +- **Ecosystem.** React is the most widely used UI library on the web. Design systems, charts, tables, and maps are all one `npm install` away. +- **The right model.** React components are functions of state, which fits Shiny's reactive model naturally. When the server sends new data, the UI re-renders efficiently. +- **AI assistance.** LLMs have trained on an enormous amount of React code. Ask a frontier agent for a UI and it will produce better React than it will bespoke Shiny UI. This aligns with Shiny's goal that app developers should never be required to write low-level HTML or JavaScript themselves. + +Later in the post, we'll discuss a genomics app that renders a 584,000-cell UMAP on the GPU from a plain Shiny server. First, the basics. + +## Old Faithful with shinyreact + +Here is the classic Old Faithful histogram app as a shinyreact app: + +::: {.panel-tabset group="language"} +## R + +```r +library(shiny) +library(shinyreact) + +# Set up the page UI using shinyreact +ui <- page_react() + +server <- function(input, output, session) { + x <- faithful$waiting + + breaks <- reactive({ + seq(min(x), max(x), length.out = input$bin_count + 1) + }) + + # Use `reactive_output()` to send data to the client + output$dist_data <- reactive_output({ + bins <- hist(x, breaks = breaks(), plot = FALSE) + list(breaks = I(bins$breaks), counts = I(bins$counts)) + }) +} + +shinyApp(ui, server) +``` + +## Python + +```python +import numpy as np +import pandas as pd +from shiny.express import input +from shinyreact import reactive_output, set_react_page + +# Set up the page UI using shinyreact +set_react_page() + +x = pd.read_csv("faithful.csv")["waiting"].to_numpy() + + +@reactive_output +def dist_data(): + breaks = np.linspace(x.min(), x.max(), input.bin_count() + 1) + counts, _ = np.histogram(x, bins=breaks) + return {"breaks": breaks.tolist(), "counts": counts.tolist()} +``` +::: + +Two things are different from a traditional Shiny app. + +1. **The UI is one line.** `page_react()` (or `set_react_page()` in Shiny Express) serves the React client that lives in your app's `www/` directory. There's no `sliderInput()` or `plotOutput()`. The `www/` directory contains the static assets for the React app: `ui.js` and `ui.css` (when available). +2. **The output is data.** `reactive_output()` has no matching UI function. This is a new concept for the Shiny ecosystem! `renderPlot()` sends an image for `plotOutput()` to place. `reactive_output()` sends plain JSON, here the histogram's `breaks` and `counts`. Like any render function, `reactive_output()` re-executes each time its reactive dependencies change. The server sends facts and React decides how to present them. + +Now the client: + +```tsx +// src/ui.tsx (which compiles to www/ui.js) +function App() { + const [binCount, setBinCount] = useShinyInput("bin_count", 30); + const bins = useShinyOutputValue("dist_data", null); + + return ( +
+ + setBinCount(Number(e.target.value))} + /> + +
+ ); +} +``` + +![The Old Faithful app: dragging the bin-count slider re-renders the histogram as the server sends new breaks and counts.](hello-app.gif){fig-alt="A Shiny app with a range slider labeled Number of bins on the left and a histogram of Old Faithful waiting times on the right. As the slider moves from 30 to 8, 45, 20, and back to 30, the histogram redraws with that many bars and the caption updates to match."} + +If you've written React before, this is an ordinary component. `Histogram` is whatever you like: a hand-written SVG, a charting library, or a component from your design system. The only shinyreact-specific parts are two hooks: + +- `useShinyInput()` works like React's `useState()`, except that the value is also sent to the server as `input$bin_count` (or `input.bin_count()` in Python). Calling `setBinCount()` updates the UI and triggers the server's reactive graph. +- `useShinyOutputValue()` reads the value of a `reactive_output()`. When the server recomputes `dist_data`, the component re-renders with the new data. + +Those two hooks cover the vast majority of apps. + +## IDs and JSON are the contract + +The client and server share exactly two things: IDs and JSON values. If you write the client in TypeScript, each hook takes an optional type for its value, such as `useShinyInput("bin_count", 30)`, and your editor will then flag a mismatch that `Shiny.setInputValue()` never could. + +Here is the full round trip for the Old Faithful app: + +1. The client calls `useShinyInput("bin_count", 30)`, which sends `{"bin_count": 30}` to the server. +2. The server reads `input$bin_count`, runs its reactive graph, and computes `dist_data` using `reactive_output()`. +3. The client receives the `dist_data` result as `{"dist_data": {"breaks": [...], "counts": [...]}}`, and `useShinyOutputValue("dist_data")` hands it to React. + +That narrow boundary is what makes a shinyreact app easy to reason about. The server doesn't know or care how the histogram is drawn, and the client doesn't know how the bins are computed. Each side can be reviewed, tested, and rewritten independently, whether a person or an agent wrote it. + +When you need more, a few other hooks are available. `useShinyOutputStatus()` tells you when an output is recalculating so you can show a skeleton, and `useShinyMessageHandler()` receives one-off messages pushed from the server with `send_message()`. See the [JavaScript API reference](https://posit-dev.github.io/shinyreact/js/) for the full list. + +## You don't have to write the React yourself + +A fair reaction to all of this is, "but I chose Shiny so I wouldn't have to write JavaScript!". That's still the goal. What's changed is that today's AI agents are very good at writing React, far better than they are at writing custom Shiny UI, because there is so much more React in the world for them to learn from. + +With shinyreact, your job is to own the server, which is where your data and domain logic live, and to describe and review the UI. To make that concrete, both packages ship [Agent Skills](https://posit-dev.github.io/shinyreact/articles/agent-skills.html): + +- **`shinyreact-build-app`** scaffolds a new shinyreact app from a description. +- **`shinyreact-convert-app`** opens an existing Shiny app in a browser, describes what it does in plain English, and then rewrites the UI in React against the same server. + +The day-to-day loop is familiar. You edit `src/ui.tsx` (or ask an agent to), the build step writes `www/ui.js`, and you reload the running Shiny app to see the change. The server side is unchanged: `runApp()` or `shiny run`, exactly as before. + +Node.js isn't required, since a client can be a single `www/ui.js` file with no build step. However, we do strongly recommend it as it gives you a proper development environment: a build step gets you TypeScript, linting, and formatting. + +## Keep what you already have + +shinyreact doesn't ask you to throw away the rest of the Shiny ecosystem. + +- **Existing outputs.** `renderPlotly()`, `render.data_frame`, and other render functions work as before. Drop a `` into your React tree, and the output's JavaScript and CSS dependencies are delivered automatically. +- **Modules.** `ShinyModuleProvider` namespaces hook IDs to match a server-side module. +- **Bookmarking.** URL and server bookmarking seed the initial values of `useShinyInput()`. + +## Testing at every layer + +Because the client and server only share IDs and JSON, each layer can be tested on its own. The server is the layer most Shiny developers care about, and it needs no browser at all. Use `shiny::testServer()` in R, or the new `local_server` pytest fixture in [Shiny for Python 1.8](/blog/2026-09-22_shiny-python-1-8/): set inputs and assert on the JSON that comes out. + +```python +def test_histogram(local_server): + local_server.set_inputs(bin_count=10) + data = local_server.get_output("dist_data") + assert len(data["counts"]) == 10 +``` + +The other layers have their own tools: + +- **The client.** Ordinary JavaScript unit tests, with whichever test runner you (or your agent) prefer. +- **The wire.** `wire_tap()` for [shinytest2](https://rstudio.github.io/shinytest2/) and `WireTap` for Playwright record the JSON crossing the websocket during a browser test. That gives you an end-to-end assertion on what the client actually sent and what the server actually returned, without reaching into the rendered DOM. +- **The behavior.** Each example app ships a `FEATURES.md`: a nested list where every leaf is one checkable claim about the app, written in plain English. A person can read it as a spec, and an agent with a browser can walk it and turn each claim into a deterministic check against the running app. + +The [testing article](https://posit-dev.github.io/shinyreact/articles/testing.html) covers all of them. + +## In the wild: Plotomics Live + +Over the summer, Shiny intern [Samuel Bharti](https://www.samuelbharti.com) built a [collection of bioinformatics Shiny apps](https://posit-shiny-showcase-bioinformatics.share.connect.posit.cloud/). Most of them are plain Shiny and bslib. The one that reached for shinyreact did so because the visualizations demanded it. + +[Plotomics Live](https://posit-plotomics-live.share.connect.posit.cloud/) ([source](https://github.com/samuelbharti/plotomics-live), [DOI](https://doi.org/10.5281/zenodo.21936926)) is a 26-page gallery of GPU-accelerated genomics visualizations, from oncoplots to a one-million-point Xenium spatial view and an interactive 584,000-cell UMAP. Large data skips JSON entirely and moves as compact binary typed arrays straight to the GPU. Because React owns the component, a new selection updates the data in place without re-mounting the visualization or reallocating GPU buffers. + +```{=html} + +``` + +In Samuel's words: + +> R stays the analysis engine, React becomes the visualization layer, and shinyreact removes the custom JavaScript bindings, manual message passing, and serialization code that used to sit between them. + +## What's next + +We're working on two directions next: + +- **Embedding React components in existing apps**, so you can adopt shinyreact one piece at a time without porting a whole app. +- **Wrapping shinyreact in your own package**, so you can build a component once and ship it the way bslib ships its components. + +## Learn more + +- Documentation: [posit-dev.github.io/shinyreact](https://posit-dev.github.io/shinyreact/) +- Source and example apps: [github.com/posit-dev/shinyreact](https://github.com/posit-dev/shinyreact) +- posit::conf(2026) talk slides: [Beyond Bootstrap: Building Custom Shiny UI with React](https://schloerke.com/presentation-2026-09-15-posit-conf-shinyreact/) + +Give shinyreact a try, and please [let us know](https://github.com/posit-dev/shinyreact/issues) what you build and what breaks. diff --git a/content/blog/introducing-shinyreact/plotomics-live.mp4 b/content/blog/introducing-shinyreact/plotomics-live.mp4 new file mode 100644 index 000000000..b8aa48058 Binary files /dev/null and b/content/blog/introducing-shinyreact/plotomics-live.mp4 differ diff --git a/content/software/shinyreact/_index.md b/content/software/shinyreact/_index.md new file mode 100644 index 000000000..8fd65b1cd --- /dev/null +++ b/content/software/shinyreact/_index.md @@ -0,0 +1,36 @@ +--- +description: Shiny UI infrastructure for React-based component rendering +github: posit-dev/shinyreact +image: shiny-react.png +languages: +- TypeScript +- R +- Python +latest_release: '2026-09-13T20:16:52+00:00' +people: +- Barret Schloerke +title: shinyreact +website: https://posit-dev.github.io/shinyreact/ + +include: + languages: + - R + - Python + +external: # updated automatically, do not edit + description: Shiny UI infrastructure for React-based component rendering + first_commit: '2026-03-02T16:33:37+00:00' + forks: 3 + languages: + - TypeScript + last_updated: '2026-09-29T19:45:28.550123+00:00' + latest_release: '2026-09-13T20:16:52+00:00' + license: MIT + people: + - Barret Schloerke + readme_image: logo/shiny-react.png + repo: posit-dev/shinyreact + stars: 17 + title: shinyreact + website: https://posit-dev.github.io/shinyreact/ +--- diff --git a/content/software/shinyreact/shiny-react.png b/content/software/shinyreact/shiny-react.png new file mode 100644 index 000000000..0069eeb09 Binary files /dev/null and b/content/software/shinyreact/shiny-react.png differ diff --git a/data/github-repos.toml b/data/github-repos.toml index 341b16942..74e2d7dea 100644 --- a/data/github-repos.toml +++ b/data/github-repos.toml @@ -27451,3 +27451,22 @@ contributors = [ ] readme_image = "man/figures/logo.svg" last_updated = "2026-09-18T14:31:52.576012+00:00" + +[[repos]] +repo = "posit-dev/shinyreact" +name = "shinyreact" +description = "Shiny UI infrastructure for React-based component rendering" +website = "https://posit-dev.github.io/shinyreact/" +stars = 17 +forks = 3 +license = "MIT" +language = "TypeScript" +latest_release = "2026-09-13T20:16:52+00:00" +releases = 2 +first_commit = "2026-03-02T16:33:37+00:00" +contributors = [ + "schloerke", + "samuelbharti", +] +readme_image = "logo/shiny-react.png" +last_updated = "2026-09-29T19:45:28.550123+00:00"