Skip to content

feat: Support React-based Shiny components#3

Open
gadenbuie wants to merge 5 commits intowch:mainfrom
gadenbuie:feat/multiple-react-roots
Open

feat: Support React-based Shiny components#3
gadenbuie wants to merge 5 commits intowch:mainfrom
gadenbuie:feat/multiple-react-roots

Conversation

@gadenbuie
Copy link

@gadenbuie gadenbuie commented Jan 14, 2026

Fixes #2

The core idea is to bring the concept of Shiny modules to shiny-react with a ShinyModuleProvider that lets you namespace the client-side IDs to match Shiny's server-side modules patterns.

These instructions (to be added to the shiny/shiny-react skill) showcase the approach nicely:

  1. Use namespaces for multiple widget instances - When embedding multiple instances of the same React widget, wrap them in ShinyModuleProvider to prevent ID conflicts:

    import { ShinyModuleProvider } from "@posit/shiny-react";
    
    <ShinyModuleProvider namespace="widget1">
      <MyWidget />
    </ShinyModuleProvider>
  2. Create reusable widgets with custom web elements - For self-contained React widgets that can be embedded in Shiny apps, use custom web elements. See the "Custom Web Element Pattern" section below for the recommended approach.

Shiny Module Namespaces

When to use namespaces:

  • Multiple widget instances - Same React component used multiple times on one page
  • Shiny module integration - React widgets inside Shiny modules (moduleServer in R, @module.server in Python)
  • Reusable components - Creating widget libraries that work like standard Shiny UI components

Client-Side Pattern

import { ShinyModuleProvider } from "@posit/shiny-react";

// Wrap the widget in ShinyModuleProvider
<ShinyModuleProvider namespace={namespace}>
  <CounterWidget />
</ShinyModuleProvider>

// All hooks inside automatically namespace their IDs
function CounterWidget() {
  const [count, setCount] = useShinyInput<number>("count", 0);
  // If namespace="counter1", this becomes "counter1-count"
}

Server-Side Pattern

Use standard Shiny module patterns. The post_message() function automatically namespaces messages:

R:

counter_ui <- function(id, title = "Counter") {
  card(
    card_header(title),
    tags$tag("counter-widget", list(`data-namespace` = id))
  )
}

counter_server <- function(id) {
  moduleServer(id, function(input, output, session) {
    # input$count is automatically namespaced by Shiny
    output$serverCount <- render_json({ input$count * 2 })

    # post_message automatically applies session$ns()
    post_message(session, "notification", list(text = "Updated!"))

    # Return reactive for use elsewhere
    reactive({ input$count })
  })
}

Because shiny-react components have different lifecycle needs, I wrapped up all of the boilerplate you'd end up needing to implement in a custom web element into a base ShinyReactComponentElement custom element that in the most simple case you just need to extend and tell it which component it wraps:

import { ShinyReactComponentElement } from "@posit/shiny-react";
import { CounterWidget } from "./CounterWidget";

class CounterWidgetElement extends ShinyReactComponentElement {
  static component = CounterWidget;
}

if (!customElements.get("counter-widget")) {
  customElements.define("counter-widget", CounterWidgetElement);
}

That's it! The base class automatically:

  • Creates a React root and renders your component
  • Wraps in ShinyModuleProvider if the element has an id attribute
  • Parses data-* attributes into props via getConfig() (with JSON auto-parsing)
  • Cleans up React and Shiny bindings on disconnect

For elements that need to wrap Shiny UI/inputs/output, ShinyReactComponentElement grabs it's children and makes them available to the be used in the React component. Groups of elements can be re-routed using the data-slot attribute which gives each slot a name.

import { ShinyReactComponentElement } from "@posit/shiny-react";
import { SidebarLayout } from "./SidebarLayout";

class SidebarLayoutElement extends ShinyReactComponentElement {
  protected render() {
    const config = this.getConfig();
    return (
      <SidebarLayout
        {...config}
        onSlotMount={this.onSlotMount}  // Pass the slot mounting callback
      />
    );
  }
}

if (!customElements.get("react-sidebar-layout")) {
  customElements.define("react-sidebar-layout", SidebarLayoutElement);
}

In your React component, call onSlotMount(slotName, containerElement) after the container mounts to move Shiny content into place.

Slot naming:

  • Use data-slot="name" attributes in R/Python to create named slots
  • If no data-slot elements exist, all children are captured as __children__

Implements a base custom element class `ShinyReactComponentElement` that handles the React lifecycle, parsing config from attributes, wrapping the component in an ID namespace, and allowing the component to accept Shiny UI/inputs/outputs seamlessly as children with named slots.

In most cases, this makes it super simple to create a new Shiny-React component by just extending this base class and specifying the React component to render.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Supporting React-based Shiny components

1 participant