`
+- Self-contained: all widget logic in one place
+
+See [examples/8-modules/](examples/8-modules/) for complete working examples.
+
+## Shiny Module Namespaces
+
+Shiny-React supports Shiny module namespaces, enabling multiple independent React components on a single page without ID conflicts. This is essential when:
+
+- Embedding multiple instances of the same React widget
+- Integrating React components with Shiny modules (`moduleServer` in R, `@module.server` in Python)
+- Creating reusable React widgets that work like standard Shiny UI components
+
+### Using ShinyModuleProvider
+
+Wrap your React components in `ShinyModuleProvider` to automatically namespace all hooks:
+
+```typescript
+import { ShinyModuleProvider } from '@posit/shiny-react';
+
+
+
+
+```
+
+All hooks inside the provider (`useShinyInput`, `useShinyOutput`, `useShinyMessageHandler`, and `ImageOutput`) will automatically prefix their IDs with the module namespace using a `-` separator (e.g., `count` becomes `counter1-count`).
+
+### Explicit Namespace Option
+
+Alternatively, pass a `namespace` option directly to hooks:
+
+```typescript
+const [value, setValue] = useShinyInput("count", 0, { namespace: "counter1" });
+// Connects to input$counter1-count in R or input.counter1_count() in Python
+```
+
+The explicit option overrides any context-provided namespace.
+
+### Server-Side Integration
+
+On the server side, use Shiny's standard module pattern. The `post_message()` function automatically applies namespacing via `session$ns()` (R) or `resolve_id()` (Python):
+
+**R Example:**
+```r
+counter_server <- function(id) {
+ moduleServer(id, function(input, output, session) {
+ # input$count is automatically namespaced
+ output$serverCount <- render_json({ input$count * 2 })
+
+ # Messages are automatically namespaced via session$ns()
+ post_message(session, "notification", list(text = "Updated!"))
+ })
+}
+```
+
+**Python Example:**
+```python
+@module.server
+def counter_server(input, output, session):
+ @render_json
+ def serverCount():
+ return input.count() * 2
+
+ # Messages are automatically namespaced via resolve_id()
+ await post_message(session, "notification", {"text": "Updated!"})
+```
+
+### Example: Reusable React Widget
+
+See [examples/8-modules/](examples/8-modules/) for a complete example with two variants:
+
+1. **Full React app** using `page_react()` with multiple `ShinyModuleProvider` instances
+2. **Standard Shiny app** (recommended) with React widgets embedded in traditional Shiny UI, following a clean API pattern:
+
+```r
+# Create widget UI
+counter_ui <- function(id, title = "Counter") {
+ card(
+ card_header(title),
+ tags$tag("counter-widget", list(id = id))
+ )
+}
+
+# Widget server returns reactive value
+counter_server <- function(id) {
+ moduleServer(id, function(input, output, session) {
+ # ... server logic ...
+ reactive({ input$count })
+ })
+}
+
+# Use in app
+ui <- page_fluid(
+ counter_ui("counter1", "Counter A"),
+ counter_ui("counter2", "Counter B")
+)
+
+server <- function(input, output, session) {
+ count1 <- counter_server("counter1")
+ count2 <- counter_server("counter2")
+ # Use reactive values elsewhere
+}
+```
+
+
## TypeScript/JavaScript API
### React Hooks
-- **`useShinyInput
(id, defaultValue, options?)`** - Send data from React to Shiny server with debouncing and priority control
-- **`useShinyOutput(outputId, defaultValue?)`** - Receive reactive data from Shiny server outputs
-- **`useShinyMessageHandler(messageType, handler)`** - Handle custom messages sent from Shiny server with automatic cleanup
+- **`useShinyInput(id, defaultValue, options?)`** - Send data from React to Shiny server with debouncing, priority control, and optional namespace
+- **`useShinyOutput(outputId, defaultValue?, options?)`** - Receive reactive data from Shiny server outputs with optional namespace
+- **`useShinyMessageHandler(messageType, handler, options?)`** - Handle custom messages sent from Shiny server with automatic cleanup and optional namespace
- **`useShinyInitialized()`** - Hook to determine when Shiny has finished initializing
### Components
-- **`ImageOutput`** - Display Shiny image/plot outputs with automatic sizing
+- **`ImageOutput`** - Display Shiny image/plot outputs with automatic sizing and optional namespace
+- **`ShinyModuleProvider`** - Context provider for automatic namespace application to child hooks
### Options
-Input options support debouncing (`debounceMs`) and event priority (`priority`) for fine-grained control over server communication timing.
+- **Debouncing** (`debounceMs`) - Control timing of server communication (default: 100ms for inputs)
+- **Event Priority** (`priority`) - Use `"event"` for button clicks to ensure each event is captured
+- **Namespace** (`namespace`) - Apply Shiny module namespace to IDs (available in all hooks and `ImageOutput`)
@@ -283,3 +492,27 @@ Key features demonstrated:

+### Shiny Module Namespaces
+
+The [examples/8-modules/](examples/8-modules/) directory demonstrates how to use Shiny module namespaces to create multiple independent React widgets on a single page. This example includes two variants:
+
+1. **Full React App** (`app.R` / `app.py`) - Single-page React application using `page_react()` with multiple `ShinyModuleProvider` instances
+2. **Standard Shiny App** (`app-standard.R` / `app-standard.py`) - Traditional Shiny/bslib app embedding React widgets as reusable components (recommended for integration)
+
+Key features demonstrated:
+- **Module Namespacing** - Multiple widget instances without ID conflicts
+- **Independent State** - Each widget maintains its own state
+- **Custom Element Pattern** - Uses `` for semantic initialization
+- **Communication Patterns** - Demonstrates inputs, outputs, and messages with namespacing
+- **Reactive Return Values** - Server functions return reactive values for integration
+- **Clean API** - Simple `counter_ui()` and `counter_server()` functions following Shiny conventions
+
+The standard app variant shows the recommended pattern for embedding React widgets in traditional Shiny applications, making React components feel like native Shiny UI components.
+
+**Run it:**
+```bash
+cd examples/8-modules
+npm install
+npm run dev-standard # Runs both R and Python variants
+```
+
diff --git a/examples/1-hello-world/py/shinyreact.py b/examples/1-hello-world/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/1-hello-world/py/shinyreact.py
+++ b/examples/1-hello-world/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/1-hello-world/r/shinyreact.R b/examples/1-hello-world/r/shinyreact.R
index 6c5b402..f7dbe1c 100644
--- a/examples/1-hello-world/r/shinyreact.R
+++ b/examples/1-hello-world/r/shinyreact.R
@@ -66,14 +66,21 @@ render_json <- function(
#' React components using useShinyMessageHandler() hook. This wraps messages in a
#' standard format and sends them via the "shinyReactMessage" channel.
#'
+#' When called from within a Shiny module, the message type is automatically
+#' namespaced using session$ns() to match the React component's namespace.
+#'
#' @param session The Shiny session object
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/2-inputs/py/shinyreact.py b/examples/2-inputs/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/2-inputs/py/shinyreact.py
+++ b/examples/2-inputs/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/2-inputs/r/shinyreact.R b/examples/2-inputs/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/2-inputs/r/shinyreact.R
+++ b/examples/2-inputs/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/3-outputs/py/shinyreact.py b/examples/3-outputs/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/3-outputs/py/shinyreact.py
+++ b/examples/3-outputs/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/3-outputs/r/shinyreact.R b/examples/3-outputs/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/3-outputs/r/shinyreact.R
+++ b/examples/3-outputs/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/4-messages/py/shinyreact.py b/examples/4-messages/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/4-messages/py/shinyreact.py
+++ b/examples/4-messages/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/4-messages/r/shinyreact.R b/examples/4-messages/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/4-messages/r/shinyreact.R
+++ b/examples/4-messages/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/5-shadcn/py/shinyreact.py b/examples/5-shadcn/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/5-shadcn/py/shinyreact.py
+++ b/examples/5-shadcn/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/5-shadcn/r/shinyreact.R b/examples/5-shadcn/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/5-shadcn/r/shinyreact.R
+++ b/examples/5-shadcn/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/6-dashboard/py/shinyreact.py b/examples/6-dashboard/py/shinyreact.py
index c7c9769..fe82475 100644
--- a/examples/6-dashboard/py/shinyreact.py
+++ b/examples/6-dashboard/py/shinyreact.py
@@ -4,6 +4,7 @@
from shiny.html_dependencies import shiny_deps
from shiny.types import Jsonifiable
from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
from typing import Any, Mapping, Optional, Sequence, Union
@@ -22,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/6-dashboard/r/shinyreact.R b/examples/6-dashboard/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/6-dashboard/r/shinyreact.R
+++ b/examples/6-dashboard/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/7-chat/py/shinyreact.py b/examples/7-chat/py/shinyreact.py
index 92559b3..fe82475 100644
--- a/examples/7-chat/py/shinyreact.py
+++ b/examples/7-chat/py/shinyreact.py
@@ -1,11 +1,11 @@
from __future__ import annotations
-from typing import Any, Mapping, Optional, Sequence, Union
-
-from shiny import Session, ui
+from shiny import ui, Session
from shiny.html_dependencies import shiny_deps
-from shiny.render.renderer import Renderer, ValueFn
from shiny.types import Jsonifiable
+from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
+from typing import Any, Mapping, Optional, Sequence, Union
def page_bare(*args: ui.TagChild, title: str | None = None, lang: str = "en") -> ui.Tag:
@@ -23,7 +23,6 @@ def page_react(
css_file: str | None = "main.css",
lang: str = "en",
) -> ui.Tag:
-
head_items: list[ui.TagChild] = []
if js_file:
@@ -90,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
React components using useShinyMessageHandler() hook. This wraps messages in
a standard format and sends them via the "shinyReactMessage" channel.
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
Parameters
----------
session
@@ -100,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn):
data
The data to send to the client
"""
- await session.send_custom_message("shinyReactMessage", {"type": type, "data": data})
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/7-chat/r/shinyreact.R b/examples/7-chat/r/shinyreact.R
index 6c5b402..a9e9980 100644
--- a/examples/7-chat/r/shinyreact.R
+++ b/examples/7-chat/r/shinyreact.R
@@ -70,10 +70,14 @@ render_json <- function(
#' @param type The message type (should match messageType in useShinyMessageHandler)
#' @param data The data to send to the client
post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
session$sendCustomMessage(
"shinyReactMessage",
list(
- type = type,
+ type = namespaced_type,
data = data
)
)
diff --git a/examples/8-modules/README.md b/examples/8-modules/README.md
new file mode 100644
index 0000000..5913091
--- /dev/null
+++ b/examples/8-modules/README.md
@@ -0,0 +1,266 @@
+# Example 8: Shiny Module Namespaces
+
+This example demonstrates how to use shiny-react with Shiny module namespaces to create multiple independent React widgets on a single page.
+
+## Two Variants
+
+This example includes two variants that demonstrate different use cases:
+
+### 1. Full React App (`app.R` / `app.py`)
+
+A single-page React application that uses `page_react()`. This is best for when your entire UI is built in React.
+
+**Features:**
+- Uses `page_react()` to create a full React app
+- All UI defined in React components
+- Three counter instances with different namespaces
+
+**Run it:**
+```bash
+# R version
+npm run dev-r
+
+# Python version
+npm run dev-py
+```
+
+### 2. Standard Shiny App (`app-standard.R` / `app-standard.py`) ⭐
+
+A traditional Shiny/bslib app that embeds React widgets as reusable components. This is the **recommended approach** for integrating React components into existing Shiny applications.
+
+**Features:**
+- Uses standard Shiny UI (bslib cards in R, standard layout in Python)
+- React widgets are embedded as custom components
+- Clean API: `counter_ui(id, title)` and `counter_server(id)`
+- The `counter_server()` function returns a reactive value for the current count
+- Demonstrates proper module pattern for reusable widgets
+- **Dynamic rendering**: Add and remove counter widgets on the fly using action buttons
+- Custom web element (``) handles automatic React initialization and cleanup
+
+**Run it:**
+```bash
+# R version (port 8000)
+npm run dev-standard-r
+
+# Python version (port 8001)
+npm run dev-standard-py
+
+# Or run both
+npm run dev-standard
+```
+
+## How the Standard Variant Works
+
+### Client-Side (React)
+
+The React widget uses a custom web element that automatically initializes when added to the DOM:
+
+```tsx
+// main.tsx
+class CounterWidgetElement extends HTMLElement {
+ private root: Root | null = null;
+
+ connectedCallback() {
+ const namespace = this.id;
+ this.root = createRoot(this);
+ this.root.render(
+
+
+
+
+
+ );
+ }
+
+ disconnectedCallback() {
+ // Clean up React root when element is removed
+ if (this.root) {
+ this.root.unmount();
+ this.root = null;
+ }
+ }
+}
+
+customElements.define("counter-widget", CounterWidgetElement);
+```
+
+This custom element approach:
+- Uses the standard HTML `id` attribute (familiar to Shiny users)
+- Automatically initializes React when the element is added to the DOM
+- Properly cleans up when the element is removed (important for dynamic rendering)
+- Works seamlessly with Shiny's `insertUI()` and `removeUI()` functions
+
+**Configuration Pattern:** You can pass additional configuration by reading HTML attributes via `dataset` in `connectedCallback()` and passing them as props to your React component:
+
+```typescript
+connectedCallback() {
+ const namespace = this.id;
+ const title = this.dataset.title || "Default";
+ const initialValue = parseInt(this.dataset.initialValue || "0");
+
+ this.root.render(
+
+
+
+ );
+}
+```
+
+Then from R/Python:
+```r
+tag("counter-widget", list(id = id, `data-title` = "My Title", `data-initial-value` = 10))
+```
+
+### Server-Side API
+
+#### R Example
+
+```r
+# UI function creates a bslib card with the counter widget
+counter_ui <- function(id, title = "Counter") {
+ card(
+ card_header(title),
+ tag(
+ "counter-widget",
+ list(id = id) # Standard HTML id attribute
+ )
+ )
+}
+
+# Server function returns a reactive with the current count
+counter_server <- function(id) {
+ moduleServer(id, function(input, output, session) {
+ # ... server logic ...
+
+ reactive({ input$count }) # Return reactive value
+ })
+}
+
+# Usage in app
+ui <- page_fluid(
+ counter_ui("counter1", "Counter A"),
+ counter_ui("counter2", "Counter B")
+)
+
+server <- function(input, output, session) {
+ count1 <- counter_server("counter1")
+ count2 <- counter_server("counter2")
+
+ # Use the reactive values
+ observe({
+ print(count1())
+ print(count2())
+ })
+}
+```
+
+#### Python Example
+
+```python
+def counter_ui(id: str, title: str = "Counter"):
+ """Creates a card with the counter widget"""
+ return ui.card(
+ ui.card_header(title),
+ ui.HTML(f' ')
+ )
+
+@module.server
+def counter_server(input, output, session):
+ """Server logic, returns reactive with current count"""
+ # ... server logic ...
+
+ @reactive.calc
+ def count():
+ return input.count() if input.count() is not None else 0
+
+ return count # Return reactive value
+
+# Usage in app
+def server(input, output, session):
+ count1 = counter_server("counter1")
+ count2 = counter_server("counter2")
+
+ # Use the reactive values
+ @reactive.effect
+ def _():
+ print(count1())
+ print(count2())
+```
+
+## Key Features Demonstrated
+
+1. **Module Namespacing**: Each widget has its own namespace, preventing ID conflicts
+2. **Independent State**: Each counter maintains its own state
+3. **Custom Web Element**: Uses `` custom element with automatic lifecycle management
+4. **Dynamic Rendering**: Add and remove widgets dynamically using `insertUI()`/`removeUI()` (R) or `ui.insert_ui()`/`ui.remove_ui()` (Python)
+5. **Communication Patterns**:
+ - **Inputs**: Client count → Server (via `useShinyInput`)
+ - **Outputs**: Server doubled value → Client (via `useShinyOutput`)
+ - **Messages**: Server notifications → Client (via `useShinyMessageHandler`)
+6. **Reactive Return Values**: Server functions return reactive values that can be used elsewhere in the app
+7. **Clean API**: Simple `counter_ui()` and `counter_server()` functions that follow Shiny module patterns
+
+## Build Commands
+
+```bash
+# Build everything (both variants for R and Python)
+npm run build
+
+# Build individual variants
+npm run build-app # Full React app only
+npm run build-standard # Standard app only
+
+# Development mode (auto-rebuild + run app)
+npm run dev # Full React app (both R and Python, ports 8000/8001)
+npm run dev-standard # Standard app (both R and Python, ports 8000/8001)
+
+# Run specific variants
+npm run dev-r # Full React app (R only, port 8000)
+npm run dev-py # Full React app (Python only, port 8001)
+npm run dev-standard-r # Standard app (R only, port 8000)
+npm run dev-standard-py # Standard app (Python only, port 8001)
+```
+
+### Build Output
+
+The build process creates separate JavaScript and CSS files for each variant to avoid conflicts:
+
+- **Full React app**: `app.js` and `app.css` - Single-page React application
+- **Standard app**: `widget.js` and `widget.css` - React widgets for embedding
+
+## Directory Structure
+
+```
+8-modules/
+├── srcts/ # Full React app source
+│ ├── App.tsx
+│ ├── CounterWidget.tsx
+│ ├── main.tsx
+│ └── styles.css
+├── srcts-standard/ # Standard app widget source
+│ ├── CounterWidget.tsx
+│ ├── main.tsx
+│ └── styles.css
+├── r/
+│ ├── app.R # Full React app
+│ ├── app-standard.R # Standard app with bslib
+│ └── shinyreact.R
+├── py/
+│ ├── app.py # Full React app
+│ ├── app-standard.py # Standard app
+│ └── shinyreact.py
+└── package.json
+```
+
+## Which Variant Should I Use?
+
+- **Use the Standard variant** (`app-standard`) if you're:
+ - Adding React components to an existing Shiny app
+ - Building a traditional Shiny app that needs some React widgets
+ - Want to follow familiar Shiny module patterns
+ - Need to return reactive values from your widgets
+
+- **Use the Full React variant** (`app`) if you're:
+ - Building an entirely new app with React
+ - Want full control over the entire UI in React
+ - Don't need traditional Shiny UI components
diff --git a/examples/8-modules/package.json b/examples/8-modules/package.json
new file mode 100644
index 0000000..7ee89e6
--- /dev/null
+++ b/examples/8-modules/package.json
@@ -0,0 +1,55 @@
+{
+ "private": true,
+ "name": "shiny-react-modules",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "Shiny module namespace example using shiny-react",
+ "scripts": {
+ "build": "concurrently -c auto \"npm run build-app\" \"npm run build-standard\" \"tsc --noEmit\"",
+ "build-app": "concurrently -c auto \"npm run build-app-r\" \"npm run build-app-py\"",
+ "build-app-r": "esbuild srcts/main.tsx --bundle --minify --outdir=r/www --entry-names=app --format=esm --alias:react=react",
+ "build-app-py": "esbuild srcts/main.tsx --bundle --minify --outdir=py/www --entry-names=app --format=esm --alias:react=react",
+ "build-standard": "concurrently -c auto \"npm run build-standard-r\" \"npm run build-standard-py\"",
+ "build-standard-r": "esbuild srcts-standard/main.tsx --bundle --minify --outdir=r/www --entry-names=widget --format=esm --alias:react=react",
+ "build-standard-py": "esbuild srcts-standard/main.tsx --bundle --minify --outdir=py/www --entry-names=widget --format=esm --alias:react=react",
+ "dev": "concurrently -c auto \"npm run watch-app\" \"npm run shinyapp\"",
+ "dev-r": "concurrently -c auto \"npm run watch-app-r\" \"npm run shinyapp-r\"",
+ "dev-py": "concurrently -c auto \"npm run watch-app-py\" \"npm run shinyapp-py\"",
+ "dev-standard": "concurrently -c auto \"npm run watch-standard\" \"npm run shinyapp-standard\"",
+ "dev-standard-r": "concurrently -c auto \"npm run watch-standard-r\" \"npm run shinyapp-standard-r\"",
+ "dev-standard-py": "concurrently -c auto \"npm run watch-standard-py\" \"npm run shinyapp-standard-py\"",
+ "watch-app": "concurrently -c auto \"npm run watch-app-r\" \"npm run watch-app-py\" \"tsc --noEmit --watch --preserveWatchOutput\"",
+ "watch-app-r": "esbuild srcts/main.tsx --bundle --outdir=r/www --entry-names=app --sourcemap --format=esm --alias:react=react --watch",
+ "watch-app-py": "esbuild srcts/main.tsx --bundle --outdir=py/www --entry-names=app --sourcemap --format=esm --alias:react=react --watch",
+ "watch-standard": "concurrently -c auto \"npm run watch-standard-r\" \"npm run watch-standard-py\"",
+ "watch-standard-r": "esbuild srcts-standard/main.tsx --bundle --outdir=r/www --entry-names=widget --sourcemap --format=esm --alias:react=react --watch",
+ "watch-standard-py": "esbuild srcts-standard/main.tsx --bundle --outdir=py/www --entry-names=widget --sourcemap --format=esm --alias:react=react --watch",
+ "shinyapp": "concurrently -c auto \"npm run shinyapp-r\" \"npm run shinyapp-py\"",
+ "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"",
+ "shinyapp-py": "cd py && shiny run app.py --reload --port ${PY_PORT:-8001}",
+ "shinyapp-standard": "concurrently -c auto \"npm run shinyapp-standard-r\" \"npm run shinyapp-standard-py\"",
+ "shinyapp-standard-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app-standard.R', port=${R_PORT:-8000})\"",
+ "shinyapp-standard-py": "cd py && shiny run app-standard.py --reload --port ${PY_PORT:-8001}",
+ "clean": "rm -rf r/www py/www"
+ },
+ "author": "Winston Chang",
+ "license": "MIT",
+ "devDependencies": {
+ "@types/react": "^19.1.12",
+ "@types/react-dom": "^19.1.9",
+ "concurrently": "^9.0.1",
+ "esbuild": "^0.25.9",
+ "react": "19.1.1",
+ "react-dom": "19.1.1",
+ "typescript": "^5.9.2"
+ },
+ "dependencies": {
+ "@posit/shiny-react": "file:../.."
+ },
+ "exampleMetadata": {
+ "title": "Module Namespaces",
+ "description": "Multiple React widgets using Shiny module namespaces",
+ "deployToShinylive": true,
+ "comment": ""
+ }
+}
diff --git a/examples/8-modules/py/app-standard.py b/examples/8-modules/py/app-standard.py
new file mode 100644
index 0000000..406ed15
--- /dev/null
+++ b/examples/8-modules/py/app-standard.py
@@ -0,0 +1,214 @@
+from pathlib import Path
+
+from shiny import App, module, reactive, render, ui
+import shinyreact
+
+
+def counter_ui(id: str, title: str = "Counter"):
+ """
+ Counter Widget UI
+
+ Creates a React-powered counter widget card that can be used in a standard
+ Shiny app. The counter maintains its state independently using Shiny modules.
+
+ Args:
+ id: Module ID for namespacing
+ title: Title to display on the card
+
+ Returns:
+ UI elements for the counter widget
+ """
+ return ui.card(
+ ui.card_header(title),
+ # Custom element for the React component
+ # The counter-widget element is automatically initialized by the custom element
+ # The id attribute provides the module namespace
+ ui.HTML(f' '),
+ # Load the React widget bundle
+ ui.head_content(
+ ui.include_css(Path(__file__).parent / "www/widget.css"),
+ ui.include_js(Path(__file__).parent / "www/widget.js"),
+ ),
+ )
+
+
+@module.server
+def counter_server(input, output, session):
+ """
+ Counter Widget Server
+
+ Server logic for the counter widget. Handles the reactive communication
+ between the React component and Shiny server.
+
+ Returns:
+ A reactive value containing the current count
+ """
+
+ @output(id="serverCount")
+ @shinyreact.render_json
+ def _():
+ """Double the count value and send to client"""
+ if input.count() is not None:
+ return input.count() * 2
+ return 0
+
+ @reactive.effect
+ async def _():
+ """Send notification message every 5 counts"""
+ count = input.count()
+ if count is not None and count > 0 and count % 5 == 0:
+ await shinyreact.post_message(
+ session,
+ "notification",
+ {"message": f"Milestone reached: {count}"},
+ )
+
+ # Return a reactive containing the current count
+ @reactive.calc
+ def count():
+ return input.count() if input.count() is not None else 0
+
+ return count
+
+
+# UI
+app_ui = ui.page_fluid(
+ # Header
+ ui.div(
+ {"class": "container mt-4"},
+ ui.h1("Shiny React Counter Widgets"),
+ ui.p(
+ "React-powered counter components in a traditional Shiny app.",
+ class_="lead",
+ ),
+ ui.hr(),
+ ),
+ # Static counter widgets in a grid layout
+ ui.layout_columns(
+ counter_ui("counter1", "Counter A"),
+ counter_ui("counter2", "Counter B"),
+ counter_ui("counter3", "Counter C"),
+ col_widths=(4, 4, 4),
+ ),
+ # Dynamic widget management section
+ ui.div(
+ {"class": "container mt-4"},
+ ui.card(
+ ui.card_header("Dynamic Widget Management"),
+ ui.card_body(
+ ui.p("Test dynamic rendering by adding and removing counter widgets:"),
+ ui.div(
+ {"class": "d-flex gap-2 mb-3"},
+ ui.input_action_button("add_widget", "Add Counter", class_="btn-primary"),
+ ui.input_action_button("remove_widget", "Remove Last Counter", class_="btn-secondary"),
+ ),
+ ui.div(
+ id="dynamic_widgets_container",
+ style="display: grid;"
+ "grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));"
+ "gap: 1rem;",
+ ),
+ ),
+ ),
+ ),
+ # Summary section showing the reactive values
+ ui.div(
+ {"class": "container mt-4"},
+ ui.card(
+ ui.card_header("Current Counts (from server)"),
+ ui.card_body(
+ ui.p("These values are returned by the counter_server() function:"),
+ ui.output_text_verbatim("counts_summary"),
+ ),
+ ),
+ ),
+ # Info section
+ ui.div(
+ {"class": "container mt-4 mb-4"},
+ ui.card(
+ ui.card_header("How It Works"),
+ ui.card_body(
+ ui.tags.ul(
+ ui.tags.li(
+ ui.tags.strong("counter_ui()"),
+ " creates a card with a mount point for the React widget",
+ ),
+ ui.tags.li(
+ ui.tags.strong("counter_server()"),
+ " sets up the Shiny module logic and returns a reactive",
+ ),
+ ui.tags.li(
+ "Each widget operates independently thanks to Shiny module namespacing"
+ ),
+ ui.tags.li(
+ "The React components use ",
+ ui.tags.code("ShinyModuleProvider"),
+ " to automatically namespace all hooks",
+ ),
+ )
+ ),
+ ),
+ ),
+)
+
+
+# Server
+def server(input, output, session):
+ # Initialize counter servers and capture their reactive return values
+ count1 = counter_server("counter1")
+ count2 = counter_server("counter2")
+ count3 = counter_server("counter3")
+
+ # Track dynamically added widgets
+ dynamic_widget_ids = reactive.value([])
+ next_widget_num = reactive.value(1)
+
+ @reactive.effect
+ @reactive.event(input.add_widget)
+ def _():
+ """Add a new dynamic widget"""
+ widget_num = next_widget_num()
+ widget_id = f"dynamic{widget_num}"
+
+ # Insert the widget UI
+ ui.insert_ui(
+ ui.div(
+ {"id": f"widget_wrapper_{widget_id}", "class": "mb-3"},
+ counter_ui(widget_id, f"Dynamic Counter {widget_num}"),
+ ),
+ selector="#dynamic_widgets_container",
+ where="beforeEnd",
+ )
+
+ # Initialize the server for this widget
+ counter_server(widget_id)
+
+ # Track the widget ID
+ ids = dynamic_widget_ids()
+ dynamic_widget_ids.set(ids + [widget_id])
+
+ # Increment counter for next widget
+ next_widget_num.set(widget_num + 1)
+
+ @reactive.effect
+ @reactive.event(input.remove_widget)
+ def _():
+ """Remove the last dynamic widget"""
+ ids = dynamic_widget_ids()
+
+ if len(ids) > 0:
+ # Get the last widget ID
+ last_id = ids[-1]
+
+ # Remove the UI
+ ui.remove_ui(selector=f"#widget_wrapper_{last_id}")
+
+ # Remove from tracking
+ dynamic_widget_ids.set(ids[:-1])
+
+ @render.text
+ def counts_summary():
+ return f"Counter A: {count1()}\nCounter B: {count2()}\nCounter C: {count3()}"
+
+
+app = App(app_ui, server)
diff --git a/examples/8-modules/py/app.py b/examples/8-modules/py/app.py
new file mode 100644
index 0000000..977c12a
--- /dev/null
+++ b/examples/8-modules/py/app.py
@@ -0,0 +1,42 @@
+from shiny import App, module, reactive, ui
+import shinyreact
+
+
+# Module server function
+@module.server
+def counter_module_server(input, output, session):
+ @output(id="serverCount")
+ @shinyreact.render_json
+ def _():
+ """Double the count value and send to client"""
+ if input.count() is not None:
+ return input.count() * 2
+ return 0
+
+ @reactive.effect
+ def _():
+ """Send notification message every 5 counts"""
+ count = input.count()
+ if count is not None and count > 0 and count % 5 == 0:
+ shinyreact.post_message(
+ session,
+ "notification",
+ {"message": f"Milestone reached: {count}"},
+ )
+
+
+def server(input, output, session):
+ # Initialize three independent module servers
+ counter_module_server("counter1")
+ counter_module_server("counter2")
+ counter_module_server("counter3")
+
+
+app = App(
+ shinyreact.page_react(
+ title="Modules - Shiny React",
+ js_file="app.js",
+ css_file="app.css",
+ ),
+ server,
+)
diff --git a/examples/8-modules/py/shinyreact.py b/examples/8-modules/py/shinyreact.py
new file mode 100644
index 0000000..fe82475
--- /dev/null
+++ b/examples/8-modules/py/shinyreact.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from shiny import ui, Session
+from shiny.html_dependencies import shiny_deps
+from shiny.types import Jsonifiable
+from shiny.render.renderer import Renderer, ValueFn
+from shiny.module import resolve_id
+from typing import Any, Mapping, Optional, Sequence, Union
+
+
+def page_bare(*args: ui.TagChild, title: str | None = None, lang: str = "en") -> ui.Tag:
+ return ui.tags.html(
+ ui.tags.head(ui.tags.title(title)),
+ ui.tags.body(shiny_deps(False), *args),
+ lang=lang,
+ )
+
+
+def page_react(
+ *args: ui.TagChild,
+ title: str | None = None,
+ js_file: str | None = "main.js",
+ css_file: str | None = "main.css",
+ lang: str = "en",
+) -> ui.Tag:
+ head_items: list[ui.TagChild] = []
+
+ if js_file:
+ head_items.append(ui.tags.script(src=js_file, type="module"))
+ if css_file:
+ head_items.append(ui.tags.link(href=css_file, rel="stylesheet"))
+
+ return page_bare(
+ ui.head_content(*head_items),
+ ui.div(id="root"),
+ *args,
+ title=title,
+ lang=lang,
+ )
+
+
+class render_json(Renderer[Jsonifiable]):
+ """
+ Reactively render arbitrary JSON object.
+
+ This is a generic renderer that can be used to render any Jsonifiable data.
+ It sends the data to the client-side and let the client-side code handle the
+ rendering.
+
+ Returns
+ -------
+ :
+ A decorator for a function that returns a Jsonifiable object.
+
+ """
+
+ def __init__(
+ self,
+ _fn: Optional[ValueFn[Any]] = None,
+ ) -> None:
+ super().__init__(_fn)
+
+ async def transform(self, value: Jsonifiable) -> Jsonifiable:
+ return value
+
+
+# This is like Jsonifiable, but where Jsonifiable uses Dict, List, and Tuple,
+# this replaces those with Mapping and Sequence. Because Dict and List are
+# invariant, it can cause problems when a parameter is specified as Jsonifiable;
+# the replacements are covariant, which solves these problems.
+JsonifiableIn = Union[
+ str,
+ int,
+ float,
+ bool,
+ None,
+ Sequence["JsonifiableIn"],
+ "JsonifiableMapping",
+]
+
+JsonifiableMapping = Mapping[str, JsonifiableIn]
+
+
+async def post_message(session: Session, type: str, data: JsonifiableIn):
+ """
+ Send a custom message to the client.
+
+ A convenience function for sending custom messages from the Shiny server to
+ React components using useShinyMessageHandler() hook. This wraps messages in
+ a standard format and sends them via the "shinyReactMessage" channel.
+
+ When used within a Shiny module (@module.server), the type is automatically
+ namespaced using resolve_id(). Outside of modules, the type is passed through
+ unchanged.
+
+ Parameters
+ ----------
+ session
+ The Shiny session object
+ type
+ The message type (should match the messageType in
+ useShinyMessageHandler)
+ data
+ The data to send to the client
+ """
+ namespaced_type = resolve_id(type)
+ await session.send_custom_message(
+ "shinyReactMessage", {"type": namespaced_type, "data": data}
+ )
diff --git a/examples/8-modules/r/app-standard.R b/examples/8-modules/r/app-standard.R
new file mode 100644
index 0000000..933fe4b
--- /dev/null
+++ b/examples/8-modules/r/app-standard.R
@@ -0,0 +1,236 @@
+library(shiny)
+library(bslib)
+source("shinyreact.R", local = TRUE)
+
+#' Counter Widget UI
+#'
+#' Creates a React-powered counter widget card that can be used in a standard
+#' Shiny app. The counter maintains its state independently using Shiny modules.
+#'
+#' @param id Module ID for namespacing
+#' @param title Title to display on the card
+#'
+#' @return A bslib card containing the counter widget
+counter_ui <- function(id, title = "Counter") {
+ ns <- NS(id)
+
+ card(
+ card_header(title),
+ # Custom element for the React component
+ # The counter-widget element is automatically initialized by the custom element
+ # The id attribute provides the module namespace
+ tag(
+ "counter-widget",
+ list(
+ id = id,
+ class = "counter-widget-container"
+ )
+ ),
+ # Load the React widget bundle
+ tags$head(
+ tags$script(src = "widget.js", type = "module"),
+ tags$link(href = "widget.css", rel = "stylesheet")
+ )
+ )
+}
+
+#' Counter Widget Server
+#'
+#' Server logic for the counter widget. Handles the reactive communication
+#' between the React component and Shiny server.
+#'
+#' @param id Module ID (must match the ID used in counter_ui)
+#'
+#' @return A reactive value containing the current count
+counter_server <- function(id) {
+ moduleServer(id, function(input, output, session) {
+ # Double the count value and send to client
+ output$serverCount <- render_json({
+ req(input$count)
+ input$count * 2
+ })
+
+ # Send notification message every 5 counts
+ observe({
+ req(input$count)
+ if (input$count > 0 && input$count %% 5 == 0) {
+ post_message(
+ session,
+ "notification",
+ list(message = paste("Milestone reached:", input$count))
+ )
+ }
+ })
+
+ # Return a reactive containing the current count
+ reactive({
+ input$count
+ })
+ })
+}
+
+# UI
+ui <- page_fluid(
+ theme = bs_theme(version = 5, preset = "shiny"),
+
+ # Header
+ div(
+ class = "container mt-4",
+ h1("Shiny React Counter Widgets"),
+ p(
+ class = "lead",
+ "React-powered counter components in a traditional Shiny app using bslib."
+ ),
+ hr()
+ ),
+
+ # Static counter widgets in a grid layout
+ layout_columns(
+ col_widths = c(4, 4, 4),
+ counter_ui("counter1", "Counter A"),
+ counter_ui("counter2", "Counter B"),
+ counter_ui("counter3", "Counter C")
+ ),
+
+ # Dynamic widget management section
+ div(
+ class = "container mt-4",
+ card(
+ card_header("Dynamic Widget Management"),
+ card_body(
+ p("Test dynamic rendering by adding and removing counter widgets:"),
+ div(
+ class = "d-flex gap-2 mb-3",
+ actionButton("add_widget", "Add Counter", class = "btn-primary"),
+ actionButton(
+ "remove_widget",
+ "Remove Last Counter",
+ class = "btn-secondary"
+ )
+ ),
+ div(
+ id = "dynamic_widgets_container",
+ style = css(
+ display = "grid",
+ grid_template_columns = "repeat(auto-fit, minmax(200px, 1fr))",
+ gap = "1rem"
+ )
+ )
+ )
+ )
+ ),
+
+ # Summary section showing the reactive values
+ div(
+ class = "container mt-4",
+ card(
+ card_header("Current Counts (from server)"),
+ card_body(
+ p("These values are returned by the counter_server() function:"),
+ verbatimTextOutput("counts_summary")
+ )
+ )
+ ),
+
+ # Info section
+ div(
+ class = "container mt-4 mb-4",
+ card(
+ card_header("How It Works"),
+ card_body(
+ tags$ul(
+ tags$li(
+ tags$strong("counter_ui()"),
+ " creates a bslib card with a mount point for the React widget"
+ ),
+ tags$li(
+ tags$strong("counter_server()"),
+ " sets up the Shiny module logic and returns a reactive"
+ ),
+ tags$li(
+ "Each widget operates independently thanks to Shiny module namespacing"
+ ),
+ tags$li(
+ "The React components use ",
+ tags$code("ShinyModuleProvider"),
+ " to automatically namespace all hooks"
+ )
+ )
+ )
+ )
+ )
+)
+
+# Server
+server <- function(input, output, session) {
+ # Initialize counter servers and capture their reactive return values
+ count1 <- counter_server("counter1")
+ count2 <- counter_server("counter2")
+ count3 <- counter_server("counter3")
+
+ # Track dynamically added widgets
+ dynamic_widget_ids <- reactiveVal(character(0))
+ next_widget_num <- reactiveVal(1)
+
+ # Add a new dynamic widget
+ observeEvent(input$add_widget, {
+ widget_num <- next_widget_num()
+ widget_id <- paste0("dynamic", widget_num)
+
+ # Insert the widget UI
+ insertUI(
+ selector = "#dynamic_widgets_container",
+ where = "beforeEnd",
+ ui = div(
+ id = paste0("widget_wrapper_", widget_id),
+ class = "mb-3",
+ counter_ui(widget_id, paste("Dynamic Counter", widget_num))
+ )
+ )
+
+ # Initialize the server for this widget
+ counter_server(widget_id)
+
+ # Track the widget ID
+ ids <- dynamic_widget_ids()
+ dynamic_widget_ids(c(ids, widget_id))
+
+ # Increment counter for next widget
+ next_widget_num(widget_num + 1)
+ })
+
+ # Remove the last dynamic widget
+ observeEvent(input$remove_widget, {
+ ids <- dynamic_widget_ids()
+
+ if (length(ids) > 0) {
+ # Get the last widget ID
+ last_id <- ids[length(ids)]
+
+ # Remove the UI
+ removeUI(
+ selector = paste0("#widget_wrapper_", last_id),
+ immediate = TRUE
+ )
+
+ # Remove from tracking
+ dynamic_widget_ids(ids[-length(ids)])
+ }
+ })
+
+ # Display the current counts
+ output$counts_summary <- renderText({
+ paste0(
+ "Counter A: ",
+ count1() %||% 0,
+ "\n",
+ "Counter B: ",
+ count2() %||% 0,
+ "\n",
+ "Counter C: ",
+ count3() %||% 0
+ )
+ })
+}
+
+shinyApp(ui, server)
diff --git a/examples/8-modules/r/app.R b/examples/8-modules/r/app.R
new file mode 100644
index 0000000..96318a0
--- /dev/null
+++ b/examples/8-modules/r/app.R
@@ -0,0 +1,41 @@
+library(shiny)
+source("shinyreact.R", local = TRUE)
+
+# Module server function
+counterModuleServer <- function(id) {
+ moduleServer(id, function(input, output, session) {
+ # Double the count value and send to client
+ output$serverCount <- render_json({
+ req(input$count)
+ input$count * 2
+ })
+
+ # Send notification message every 5 counts
+ observe({
+ req(input$count)
+ if (input$count > 0 && input$count %% 5 == 0) {
+ post_message(
+ session,
+ "notification",
+ list(message = paste("Milestone reached:", input$count))
+ )
+ }
+ })
+ })
+}
+
+server <- function(input, output, session) {
+ # Initialize three independent module servers
+ counterModuleServer("counter1")
+ counterModuleServer("counter2")
+ counterModuleServer("counter3")
+}
+
+shinyApp(
+ ui = page_react(
+ title = "Modules - Shiny React",
+ js_file = "app.js",
+ css_file = "app.css"
+ ),
+ server = server
+)
diff --git a/examples/8-modules/r/shinyreact.R b/examples/8-modules/r/shinyreact.R
new file mode 100644
index 0000000..f7dbe1c
--- /dev/null
+++ b/examples/8-modules/r/shinyreact.R
@@ -0,0 +1,87 @@
+library(shiny)
+
+page_bare <- function(..., title = NULL, lang = NULL) {
+ ui <- list(
+ shiny:::jqueryDependency(),
+ if (!is.null(title)) tags$head(tags$title(title)),
+ ...
+ )
+ attr(ui, "lang") <- lang
+ ui
+}
+
+page_react <- function(
+ ...,
+ title = NULL,
+ js_file = "main.js",
+ css_file = "main.css",
+ lang = "en"
+) {
+ page_bare(
+ title = title,
+ tags$head(
+ if (!is.null(js_file)) tags$script(src = js_file, type = "module"),
+ if (!is.null(css_file)) tags$link(href = css_file, rel = "stylesheet")
+ ),
+ tags$div(id = "root"),
+ ...
+ )
+}
+
+
+#' Reactively render arbitrary JSON object data.
+#'
+#' This is a generic renderer that can be used to render any Jsonifiable data.
+#' The data goes through shiny:::toJSON() before being sent to the client.
+render_json <- function(
+ expr,
+ env = parent.frame(),
+ quoted = FALSE,
+ outputArgs = list(),
+ sep = " "
+) {
+ func <- installExprFunction(
+ expr,
+ "func",
+ env,
+ quoted,
+ label = "render_json"
+ )
+
+ createRenderFunction(
+ func,
+ function(value, session, name, ...) {
+ value
+ },
+ function(...) {
+ stop("Not implemented")
+ },
+ outputArgs
+ )
+}
+
+#' Send a custom message to the client
+#'
+#' A convenience function for sending custom messages from the Shiny server to
+#' React components using useShinyMessageHandler() hook. This wraps messages in a
+#' standard format and sends them via the "shinyReactMessage" channel.
+#'
+#' When called from within a Shiny module, the message type is automatically
+#' namespaced using session$ns() to match the React component's namespace.
+#'
+#' @param session The Shiny session object
+#' @param type The message type (should match messageType in useShinyMessageHandler)
+#' @param data The data to send to the client
+post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
+ session$sendCustomMessage(
+ "shinyReactMessage",
+ list(
+ type = namespaced_type,
+ data = data
+ )
+ )
+}
diff --git a/examples/8-modules/srcts-standard/CounterWidget.tsx b/examples/8-modules/srcts-standard/CounterWidget.tsx
new file mode 100644
index 0000000..2d576cd
--- /dev/null
+++ b/examples/8-modules/srcts-standard/CounterWidget.tsx
@@ -0,0 +1,38 @@
+import { useState } from "react";
+import {
+ useShinyInput,
+ useShinyOutput,
+ useShinyMessageHandler,
+} from "@posit/shiny-react";
+
+export function CounterWidget() {
+ const [count, setCount] = useShinyInput("count", 0);
+ const [serverCount] = useShinyOutput("serverCount", 0);
+ const [notification, setNotification] = useState(null);
+
+ useShinyMessageHandler<{ message: string }>("notification", (data) => {
+ setNotification(data.message);
+ setTimeout(() => setNotification(null), 3000);
+ });
+
+ return (
+
+
+
+ Client count:
+ {count}
+
+
+ Server doubled:
+ {serverCount}
+
+
+
setCount(count + 1)}>
+ Increment
+
+ {notification && (
+
{notification}
+ )}
+
+ );
+}
diff --git a/examples/8-modules/srcts-standard/main.tsx b/examples/8-modules/srcts-standard/main.tsx
new file mode 100644
index 0000000..b3c0287
--- /dev/null
+++ b/examples/8-modules/srcts-standard/main.tsx
@@ -0,0 +1,11 @@
+import { ShinyReactComponentElement } from "@posit/shiny-react";
+import { CounterWidget } from "./CounterWidget";
+import "./styles.css";
+
+class CounterWidgetElement extends ShinyReactComponentElement {
+ static component = CounterWidget;
+}
+
+if (!customElements.get("counter-widget")) {
+ customElements.define("counter-widget", CounterWidgetElement);
+}
diff --git a/examples/8-modules/srcts-standard/styles.css b/examples/8-modules/srcts-standard/styles.css
new file mode 100644
index 0000000..0acbd58
--- /dev/null
+++ b/examples/8-modules/srcts-standard/styles.css
@@ -0,0 +1,72 @@
+.counter-widget-content {
+ padding: 16px;
+}
+
+.counter-display {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.counter-value {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: #f8f9fa;
+ border-radius: 6px;
+}
+
+.counter-value .label {
+ font-weight: 500;
+ color: #666;
+}
+
+.counter-value .value {
+ font-size: 1.5rem;
+ font-weight: bold;
+ color: #667eea;
+}
+
+.increment-button {
+ width: 100%;
+ padding: 12px 24px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.1s;
+}
+
+.increment-button:hover {
+ opacity: 0.9;
+}
+
+.increment-button:active {
+ transform: scale(0.98);
+}
+
+.notification {
+ margin-top: 16px;
+ padding: 12px;
+ background: #d4edda;
+ border: 1px solid #c3e6cb;
+ color: #155724;
+ border-radius: 6px;
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/examples/8-modules/srcts/App.tsx b/examples/8-modules/srcts/App.tsx
new file mode 100644
index 0000000..d52dc80
--- /dev/null
+++ b/examples/8-modules/srcts/App.tsx
@@ -0,0 +1,55 @@
+import { ShinyModuleProvider } from "@posit/shiny-react";
+import CounterWidget from "./CounterWidget";
+
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
How It Works
+
+ Each counter widget is wrapped in a ShinyModuleProvider{" "}
+ with a unique namespace. This allows multiple instances of the same
+ component to operate independently without ID conflicts.
+
+
+
+ Counter 1 uses namespace counter1
+
+
+ Counter 2 uses namespace counter2
+
+
+ Counter 3 uses namespace counter3
+
+
+
+ On the server side, Shiny modules automatically namespace the outputs
+ and messages, keeping each widget's state completely separate.
+
+
+
+ );
+}
+
+export default App;
diff --git a/examples/8-modules/srcts/CounterWidget.tsx b/examples/8-modules/srcts/CounterWidget.tsx
new file mode 100644
index 0000000..9641b8c
--- /dev/null
+++ b/examples/8-modules/srcts/CounterWidget.tsx
@@ -0,0 +1,47 @@
+import { useState } from "react";
+import {
+ useShinyInput,
+ useShinyOutput,
+ useShinyMessageHandler,
+} from "@posit/shiny-react";
+
+interface CounterWidgetProps {
+ label: string;
+}
+
+function CounterWidget({ label }: CounterWidgetProps) {
+ const [count, setCount] = useShinyInput("count", 0);
+ const [serverCount] = useShinyOutput("serverCount", 0);
+ const [notification, setNotification] = useState(null);
+
+ useShinyMessageHandler<{ message: string }>("notification", (data) => {
+ setNotification(data.message);
+ setTimeout(() => setNotification(null), 3000);
+ });
+
+ return (
+
+
{label}
+
+
+
+ Client count:
+ {count}
+
+
+ Server doubled:
+ {serverCount}
+
+
+
setCount(count + 1)}>
+ Increment
+
+ {notification && (
+
{notification}
+ )}
+
+
+ );
+}
+
+export default CounterWidget;
diff --git a/examples/8-modules/srcts/main.tsx b/examples/8-modules/srcts/main.tsx
new file mode 100644
index 0000000..a68262f
--- /dev/null
+++ b/examples/8-modules/srcts/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+const container = document.getElementById("root");
+if (container) {
+ const root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+} else {
+ console.error("Could not find root element to mount React component.");
+}
diff --git a/examples/8-modules/srcts/styles.css b/examples/8-modules/srcts/styles.css
new file mode 100644
index 0000000..fce961b
--- /dev/null
+++ b/examples/8-modules/srcts/styles.css
@@ -0,0 +1,178 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
+ Ubuntu, Cantarell, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+.app-container {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.app-header {
+ text-align: center;
+ color: white;
+ margin-bottom: 40px;
+}
+
+.app-header h1 {
+ font-size: 2.5rem;
+ margin-bottom: 10px;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.subtitle {
+ font-size: 1.1rem;
+ opacity: 0.95;
+}
+
+.widgets-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 24px;
+ margin-bottom: 40px;
+}
+
+.counter-widget {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+ padding: 24px;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.counter-widget:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
+}
+
+.counter-widget h3 {
+ font-size: 1.5rem;
+ color: #333;
+ margin-bottom: 20px;
+ padding-bottom: 12px;
+ border-bottom: 2px solid #667eea;
+}
+
+.counter-content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.counter-display {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.counter-value {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.counter-value .label {
+ font-weight: 500;
+ color: #666;
+}
+
+.counter-value .value {
+ font-size: 1.5rem;
+ font-weight: bold;
+ color: #667eea;
+}
+
+.increment-button {
+ padding: 12px 24px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.1s;
+}
+
+.increment-button:hover {
+ opacity: 0.9;
+}
+
+.increment-button:active {
+ transform: scale(0.98);
+}
+
+.notification {
+ padding: 12px;
+ background: #d4edda;
+ border: 1px solid #c3e6cb;
+ color: #155724;
+ border-radius: 8px;
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.info-section {
+ background: white;
+ border-radius: 12px;
+ padding: 32px;
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+}
+
+.info-section h2 {
+ font-size: 1.8rem;
+ color: #333;
+ margin-bottom: 16px;
+}
+
+.info-section p {
+ color: #666;
+ line-height: 1.6;
+ margin-bottom: 16px;
+}
+
+.info-section code {
+ background: #f8f9fa;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: "Courier New", monospace;
+ color: #667eea;
+}
+
+.info-section ul {
+ list-style-position: inside;
+ color: #666;
+ line-height: 1.8;
+ margin-bottom: 16px;
+}
+
+.info-section ul li {
+ margin-bottom: 8px;
+}
+
+.info-section strong {
+ color: #333;
+ font-weight: 600;
+}
diff --git a/examples/8-modules/tsconfig.json b/examples/8-modules/tsconfig.json
new file mode 100644
index 0000000..b8f08e9
--- /dev/null
+++ b/examples/8-modules/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "es2022",
+ "noEmit": true,
+ "moduleResolution": "node",
+ "lib": ["es2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./srcts/*"]
+ }
+ },
+ "include": ["srcts/**/*.ts", "srcts/**/*.tsx", "srcts-standard/**/*.ts", "srcts-standard/**/*.tsx"]
+}
diff --git a/examples/9-blended/README.md b/examples/9-blended/README.md
new file mode 100644
index 0000000..1fe1efd
--- /dev/null
+++ b/examples/9-blended/README.md
@@ -0,0 +1,169 @@
+# Example 9: Blended React + Shiny UI
+
+This example demonstrates a "blended" architecture where React handles the layout and navigation chrome while native Shiny UI elements live inside each panel. This approach enables custom React-powered layouts with full Shiny reactivity and state management.
+
+## Overview
+
+The blended pattern combines the best of both worlds:
+
+- **React manages the layout**: A collapsible sidebar with navigation tabs, smooth animations, and custom styling
+- **Shiny handles the content**: Traditional Shiny inputs and outputs (`plotOutput`, `textInput`, etc.) rendered inside React containers
+- **Full reactivity preserved**: All Shiny reactive bindings work normally, with proper initialization and cleanup
+
+This is particularly useful when you want React's sophisticated UI components (like custom sidebars, navigation, or layout systems) but prefer writing application logic and outputs in R.
+
+## Key Concept: Slot Preservation Pattern
+
+The implementation uses a "slot preservation" pattern to seamlessly integrate Shiny content into React components:
+
+1. **R renders Shiny UI** as children of a custom HTML element (``)
+2. **Custom element captures children** before React renders, storing the Shiny content by panel ID
+3. **React renders the layout** with empty container refs for each panel
+4. **Content is moved** into React containers after mount using `appendChild`
+5. **`Shiny.bindAll()`** initializes bindings to activate inputs/outputs
+
+This approach preserves Shiny's DOM structure and reactive bindings while giving React full control over layout and navigation.
+
+## R API
+
+### `react_sidebar_layout(...)`
+
+Main container for the blended layout. Accepts `react_nav_panel()` children and configuration options.
+
+**Parameters:**
+- `...` - One or more `react_nav_panel()` elements
+- `id` - Optional ID for the layout container (enables Shiny module namespacing)
+- `title` - Optional title displayed in the sidebar header
+- `collapsible` - Whether sidebar can collapse (default: `TRUE`)
+- `default_open` - Whether sidebar starts open (default: `TRUE`)
+- `position` - Sidebar position: `"left"` or `"right"` (default: `"left"`)
+- `width` - Sidebar width when open (default: `"250px"`)
+
+### `react_nav_panel(title, ..., icon, value)`
+
+Defines a navigation panel containing Shiny UI elements.
+
+**Parameters:**
+- `title` - Display title for the navigation item
+- `...` - Shiny UI elements to display in the panel
+- `icon` - Optional icon (supports `bsicons::bs_icon()` or `fontawesome::fa()`)
+- `value` - Panel identifier (defaults to `title`)
+
+### Example
+
+```r
+library(shiny)
+library(bslib)
+library(bsicons)
+
+ui <- page_fillable(
+ react_sidebar_layout(
+ title = "My App",
+
+ react_nav_panel(
+ "Dashboard",
+ icon = bs_icon("bar-chart-fill"),
+ card(
+ card_header("Sales Overview"),
+ plotOutput("salesPlot")
+ ),
+ sliderInput("months", "Months:", min = 1, max = 12, value = 6)
+ ),
+
+ react_nav_panel(
+ "Settings",
+ icon = bs_icon("gear-fill"),
+ textInput("username", "Username:"),
+ checkboxInput("darkMode", "Dark Mode", FALSE)
+ )
+ )
+)
+
+server <- function(input, output, session) {
+ output$salesPlot <- renderPlot({
+ # Plot logic using input$months
+ })
+}
+
+shinyApp(ui, server)
+```
+
+## Running the Example
+
+### Prerequisites
+
+- Node.js (for building TypeScript)
+- R with packages: `shiny`, `bslib`, `bsicons`
+
+### Commands
+
+```bash
+# Install dependencies
+npm install
+
+# Build TypeScript to JavaScript
+npm run build
+
+# Run with hot reloading (auto-rebuilds on file changes)
+npm run dev-r
+```
+
+The app will be available at http://localhost:8000 (or the port specified by `R_PORT` environment variable).
+
+## Technical Details
+
+### Sidebar State Management
+
+- Collapse/expand state is managed by React's `useState`
+- Sidebar width transitions smoothly when toggled
+- Collapse button icon direction adapts to sidebar position (left/right)
+
+### Panel Switching
+
+- Uses `visibility` CSS property instead of `display: none` to preserve Shiny state
+- Active panel: `visibility: visible; position: relative`
+- Inactive panels: `visibility: hidden; position: absolute`
+- This prevents Shiny from unbinding inputs/outputs when switching panels
+
+### Icon Rendering
+
+- Icons from `bsicons::bs_icon()` or `fontawesome::fa()` are rendered as SVG strings
+- SVG content is injected using `dangerouslySetInnerHTML` in React
+- Tooltip shows full panel title when sidebar is collapsed
+
+### Module Support
+
+- Full Shiny module namespace support via `id` parameter on `react_sidebar_layout()`
+- When `id` is set, the custom element wraps React components in ``
+- All Shiny inputs/outputs inside panels are automatically namespaced
+
+## Known Limitations / Future Work
+
+- **Plot resizing**: Plots may need manual resize triggers when switching panels (Shiny plots don't automatically detect visibility changes)
+- **Complex outputs**: Some Shiny outputs with custom initialization may need additional handling in the `onPanelMount` callback
+- **Animation smoothness**: Panel switching is instant; could add fade transitions for better UX
+- **Accessibility**: Keyboard navigation between panels could be enhanced
+
+## File Structure
+
+```
+9-blended/
+├── r/
+│ ├── app.R # Main Shiny application
+│ ├── shinyreact.R # R API functions (react_sidebar_layout, etc.)
+│ └── www/ # Built assets (generated by npm run build)
+│ ├── sidebar.js # Bundled React code
+│ └── sidebar.css # Generated styles
+├── srcts/
+│ ├── main.tsx # Custom element definition & registration
+│ ├── SidebarLayout.tsx # React component for sidebar UI
+│ └── styles.css # Component styles
+├── package.json # Node.js dependencies and build scripts
+└── README.md # This file
+```
+
+## Learn More
+
+- See [shiny-react documentation](../../README.md) for more blended patterns
+- Example 8 demonstrates pure React components with Shiny communication
+- Example 10 shows advanced patterns with dynamic content injection
diff --git a/examples/9-blended/package.json b/examples/9-blended/package.json
new file mode 100644
index 0000000..b847d9a
--- /dev/null
+++ b/examples/9-blended/package.json
@@ -0,0 +1,39 @@
+{
+ "private": true,
+ "name": "shiny-react-blended",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "Blended React layout with native Shiny UI content",
+ "scripts": {
+ "build": "concurrently -c auto \"npm run build-r\" \"npm run build-py\" \"tsc --noEmit\"",
+ "build-r": "esbuild srcts/main.tsx --bundle --minify --outdir=r/www --entry-names=sidebar --format=esm --alias:react=react",
+ "build-py": "esbuild srcts/main.tsx --bundle --minify --outdir=python/www --entry-names=sidebar --format=esm --alias:react=react",
+ "dev-r": "concurrently -c auto \"npm run watch-r\" \"npm run shinyapp-r\"",
+ "dev-py": "concurrently -c auto \"npm run watch-py\" \"npm run shinyapp-py\"",
+ "watch-r": "esbuild srcts/main.tsx --bundle --outdir=r/www --entry-names=sidebar --sourcemap --format=esm --alias:react=react --watch",
+ "watch-py": "esbuild srcts/main.tsx --bundle --outdir=python/www --entry-names=sidebar --sourcemap --format=esm --alias:react=react --watch",
+ "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"",
+ "shinyapp-py": "uvx --with matplotlib,pandas,faicons,jinja2 shiny run python/app.py --port ${PY_PORT:-8001} --reload",
+ "clean": "rm -rf r/www python/www"
+ },
+ "author": "",
+ "license": "MIT",
+ "devDependencies": {
+ "@types/react": "^19.1.12",
+ "@types/react-dom": "^19.1.9",
+ "concurrently": "^9.0.1",
+ "esbuild": "^0.25.9",
+ "react": "19.1.1",
+ "react-dom": "19.1.1",
+ "typescript": "^5.9.2"
+ },
+ "dependencies": {
+ "@posit/shiny-react": "file:../.."
+ },
+ "exampleMetadata": {
+ "title": "Blended React + Shiny UI",
+ "description": "React sidebar layout containing native Shiny UI elements",
+ "deployToShinylive": true,
+ "comment": ""
+ }
+}
diff --git a/examples/9-blended/python/app.py b/examples/9-blended/python/app.py
new file mode 100644
index 0000000..26078b9
--- /dev/null
+++ b/examples/9-blended/python/app.py
@@ -0,0 +1,172 @@
+from pathlib import Path
+
+from shiny import App, ui, render, reactive
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from shinyreact import react_sidebar_layout, react_nav_panel
+import faicons as fa
+
+# Create mtcars equivalent dataset
+mtcars = pd.DataFrame({
+ 'mpg': [21.0, 21.0, 22.8, 21.4, 18.7, 18.1, 14.3, 24.4, 22.8, 19.2],
+ 'cyl': [6, 6, 4, 6, 8, 6, 8, 4, 4, 6],
+ 'disp': [160.0, 160.0, 108.0, 258.0, 360.0, 225.0, 360.0, 146.7, 140.8, 167.6],
+ 'hp': [110, 110, 93, 110, 175, 105, 245, 62, 95, 123],
+ 'drat': [3.90, 3.90, 3.85, 3.08, 3.15, 2.76, 3.21, 3.69, 3.92, 3.92]
+})
+mtcars.index = ['Mazda RX4', 'Mazda RX4 Wag', 'Datsun 710', 'Hornet 4 Drive',
+ 'Hornet Sportabout', 'Valiant', 'Duster 360', 'Merc 240D',
+ 'Merc 230', 'Merc 280']
+
+# Define UI
+app_ui = ui.page_fillable(
+ ui.tags.head(
+ ui.tags.link(
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css",
+ rel="stylesheet"
+ )
+ ),
+
+ # React sidebar layout with three panels
+ react_sidebar_layout(
+ # Panel 1: Dashboard
+ react_nav_panel(
+ "Dashboard",
+
+ # Sales Overview Card
+ ui.card(
+ ui.card_header("Sales Overview"),
+ ui.card_body(
+ ui.output_plot("salesPlot", height="300px")
+ )
+ ),
+
+ # Controls Card
+ ui.card(
+ ui.card_header("Controls"),
+ ui.card_body(
+ ui.input_slider("months", "Months:", min=1, max=12, value=6),
+ ui.input_select(
+ "region",
+ "Region:",
+ choices=["North", "South", "East", "West"]
+ )
+ )
+ ),
+
+ icon=fa.icon_svg("chart-bar"),
+ value="dashboard",
+ ),
+
+ # Panel 2: Data
+ react_nav_panel(
+ "Data",
+
+ # Data Table Card
+ ui.card(
+ ui.card_header("Data Table"),
+ ui.card_body(
+ ui.output_table("dataTable")
+ )
+ ),
+
+ # Refresh Controls
+ ui.input_action_button("refresh", "Refresh Data", class_="btn-primary mt-3"),
+ ui.output_text_verbatim("refreshCount"),
+
+ icon=fa.icon_svg("table"),
+ value="data",
+ ),
+
+ # Panel 3: Settings
+ react_nav_panel(
+ "Settings",
+
+ # Preferences Card
+ ui.card(
+ ui.card_header("Preferences"),
+ ui.card_body(
+ ui.input_text("username", "Username:"),
+ ui.input_checkbox("darkMode", "Dark Mode", False),
+ ui.input_checkbox("notifications", "Enable Notifications", True)
+ )
+ ),
+
+ # Current Settings Card
+ ui.card(
+ ui.card_header("Current Settings"),
+ ui.card_body(
+ ui.output_text_verbatim("currentSettings")
+ )
+ ),
+
+ icon=fa.icon_svg("gear"),
+ value="settings",
+ ),
+
+ title="Blended Demo",
+ ),
+ padding=0,
+)
+
+
+# Define server logic
+def server(input, output, session):
+ # Sales Plot - reactive to months and region
+ @render.plot
+ def salesPlot():
+ # Generate cumulative sales data based on inputs
+ months = np.arange(1, input.months() + 1)
+ np.random.seed(42) # For reproducibility
+ sales = np.cumsum(np.random.uniform(50, 150, input.months()))
+
+ # Create line plot
+ fig, ax = plt.subplots(figsize=(8, 4))
+ ax.plot(months, sales, linewidth=2, color='#2563eb', marker='o',
+ markersize=8, markerfacecolor='#2563eb')
+ ax.set_title(f'Sales Trend - {input.region()}', fontsize=14, fontweight='bold')
+ ax.set_xlabel('Month', fontsize=11)
+ ax.set_ylabel('Cumulative Sales ($1000s)', fontsize=11)
+ ax.grid(True, color='#e5e7eb', linestyle='-', linewidth=0.5)
+ ax.spines['top'].set_visible(False)
+ ax.spines['right'].set_visible(False)
+
+ return fig
+
+ # Data Table - showing mtcars data
+ @render.table
+ def dataTable():
+ return mtcars.head(10)
+
+ # Refresh Counter - reactive value for button clicks
+ refresh_count = reactive.value(0)
+
+ @reactive.effect
+ @reactive.event(input.refresh)
+ def _():
+ refresh_count.set(refresh_count() + 1)
+
+ @render.text
+ def refreshCount():
+ return f"Data refreshed {refresh_count()} times"
+
+ # Current Settings Display
+ @render.text
+ def currentSettings():
+ username = input.username() if len(input.username()) > 0 else "(not set)"
+ return f"""username: {username}
+darkMode: {input.darkMode()}
+notifications: {input.notifications()}"""
+
+ # Dark Mode Toggle
+ @reactive.effect
+ @reactive.event(input.darkMode)
+ def _():
+ # Note: toggle_dark_mode function would need to be implemented
+ # in shinyreact.py or as a custom message handler
+ pass
+
+
+# Run the application
+app = App(app_ui, server, static_assets=Path(__file__).parent / "www")
diff --git a/examples/9-blended/python/shinyreact.py b/examples/9-blended/python/shinyreact.py
new file mode 100644
index 0000000..ca89a7e
--- /dev/null
+++ b/examples/9-blended/python/shinyreact.py
@@ -0,0 +1,214 @@
+import json
+from shiny import ui, render, Session
+from htmltools import Tag, TagList, HTMLDependency
+
+
+def page_bare(*args, title=None, lang=None):
+ """Create a bare page with minimal dependencies."""
+ jquery_dep = HTMLDependency(
+ name="jquery",
+ version="3.6.0",
+ source={"href": "https://code.jquery.com"},
+ script={"src": "jquery-3.6.0.min.js"}
+ )
+
+ elements = [jquery_dep]
+ if title is not None:
+ elements.append(ui.tags.head(ui.tags.title(title)))
+ elements.extend(args)
+
+ result = ui.TagList(*elements)
+ if lang is not None:
+ # Store lang attribute for potential HTML wrapper
+ pass
+ return result
+
+
+def page_react(
+ *args,
+ title=None,
+ js_file="main.js",
+ css_file="main.css",
+ lang="en"
+):
+ """Create a React-enabled page with script and style dependencies."""
+ head_elements = []
+ if js_file is not None:
+ head_elements.append(ui.tags.script(src=js_file, type="module"))
+ if css_file is not None:
+ head_elements.append(ui.tags.link(href=css_file, rel="stylesheet"))
+
+ return page_bare(
+ ui.tags.head(*head_elements) if head_elements else None,
+ ui.tags.div(id="root"),
+ *args,
+ title=title
+ )
+
+
+def render_json(func=None):
+ """
+ Reactively render arbitrary JSON object data.
+
+ This is a generic renderer that can be used to render any JSON-serializable data.
+ The data is converted to JSON before being sent to the client.
+
+ Usage:
+ @render_json
+ def my_output():
+ return {"key": "value", "number": 42}
+ """
+ if func is None:
+ return lambda f: render_json(f)
+
+ @render.ui
+ def _render():
+ return func()
+
+ return _render
+
+
+def post_message(session: Session, type: str, data):
+ """
+ Send a custom message to the client.
+
+ A convenience function for sending custom messages from the Shiny server to
+ React components using useShinyMessageHandler() hook. This wraps messages in a
+ standard format and sends them via the "shinyReactMessage" channel.
+
+ When called from within a Shiny module, the message type is automatically
+ namespaced using session.ns() to match the React component's namespace.
+
+ Args:
+ session: The Shiny session object
+ type: The message type (should match messageType in useShinyMessageHandler)
+ data: The data to send to the client
+ """
+ # Apply namespace to message type using session.ns()
+ # session.ns() returns the ID unchanged if not in a module context
+ namespaced_type = session.ns(type)
+
+ session.send_custom_message(
+ "shinyReactMessage",
+ {
+ "type": namespaced_type,
+ "data": data
+ }
+ )
+
+
+def react_nav_panel(title, *args, icon=None, value=None):
+ """
+ Create a React navigation panel.
+
+ Wraps Shiny content for a navigation panel in a React sidebar layout.
+ The panel content is initially hidden and shown by React when the panel
+ is activated.
+
+ Args:
+ title: The display title for the navigation panel
+ *args: Shiny UI elements to display in the panel
+ icon: Optional icon for the panel. Can be an icon object that renders to SVG string
+ value: The value/ID for this panel (defaults to title)
+
+ Returns:
+ A div tag with appropriate data attributes for React integration
+ """
+ if value is None:
+ value = title
+
+ # icon can be an icon object - convert to string if it's a Tag
+ icon_svg = None
+ if icon is not None:
+ if isinstance(icon, (Tag, TagList)):
+ icon_svg = str(icon)
+ else:
+ icon_svg = icon
+
+ attrs = {
+ "data-slot": value,
+ "data-panel-title": title,
+ "class": "react-sidebar-panel-content",
+ "style": "display: none;" # Hidden until React takes over
+ }
+
+ if icon_svg is not None:
+ attrs["data-panel-icon"] = icon_svg
+
+ return ui.tags.div(
+ *args,
+ **attrs
+ )
+
+
+def react_sidebar_layout(
+ *args,
+ id=None,
+ title=None,
+ collapsible=True,
+ default_open=True,
+ position="left",
+ width="250px"
+):
+ """
+ Create a React sidebar layout.
+
+ Creates a custom element that combines React-based navigation with Shiny
+ content panels. The sidebar navigation is rendered by React while panel
+ content is pure Shiny UI.
+
+ Args:
+ *args: react_nav_panel() elements defining the sidebar panels
+ id: Optional ID for the layout container
+ title: Optional title to display in the sidebar header
+ collapsible: Whether the sidebar can be collapsed (default: True)
+ default_open: Whether sidebar starts open (default: True)
+ position: Sidebar position: "left" or "right" (default: "left")
+ width: Sidebar width when open (default: "250px")
+
+ Returns:
+ A custom element tag containing the sidebar layout
+ """
+ panels = list(args)
+
+ # Extract panel metadata for React
+ panel_config = []
+ for p in panels:
+ if hasattr(p, 'attrs'):
+ panel_config.append({
+ "id": p.attrs.get("data-slot"),
+ "title": p.attrs.get("data-panel-title"),
+ "icon": p.attrs.get("data-panel-icon")
+ })
+
+ # Build attributes for custom element
+ attrs = {
+ "class": "react-sidebar-layout-container",
+ "style": "display: flex; height: 100%; width: 100%;",
+ "data-panels": json.dumps(panel_config),
+ "data-collapsible": str(collapsible).lower(),
+ "data-default-open": str(default_open).lower(),
+ "data-position": position,
+ "data-width": width
+ }
+
+ if id is not None:
+ attrs["id"] = id
+
+ if title is not None:
+ attrs["data-title"] = title
+
+ # Create the custom element
+ custom_element = Tag(
+ "react-sidebar-layout",
+ attrs,
+ *panels
+ )
+
+ return ui.TagList(
+ ui.tags.head(
+ ui.tags.script(src="sidebar.js", type="module"),
+ ui.tags.link(href="sidebar.css", rel="stylesheet")
+ ),
+ custom_element
+ )
diff --git a/examples/9-blended/r/app.R b/examples/9-blended/r/app.R
new file mode 100644
index 0000000..5692e38
--- /dev/null
+++ b/examples/9-blended/r/app.R
@@ -0,0 +1,153 @@
+library(shiny)
+library(bslib)
+library(bsicons)
+
+source("shinyreact.R", local = TRUE)
+
+# Define UI
+ui <- page_fillable(
+ theme = bs_theme(version = 5, preset = "lumen"),
+ padding = 0,
+
+ # React sidebar layout with three panels
+ react_sidebar_layout(
+ title = "Blended Demo",
+
+ # Panel 1: Dashboard
+ react_nav_panel(
+ "Dashboard",
+ icon = bs_icon("bar-chart-fill"),
+ value = "dashboard",
+
+ # Sales Overview Card
+ card(
+ card_header("Sales Overview"),
+ card_body(
+ plotOutput("salesPlot", height = "300px")
+ )
+ ),
+
+ # Controls Card
+ card(
+ card_header("Controls"),
+ card_body(
+ sliderInput("months", "Months:", min = 1, max = 12, value = 6),
+ selectInput(
+ "region",
+ "Region:",
+ choices = c("North", "South", "East", "West")
+ )
+ )
+ )
+ ),
+
+ # Panel 2: Data
+ react_nav_panel(
+ "Data",
+ icon = bs_icon("table"),
+ value = "data",
+
+ # Data Table Card
+ card(
+ card_header("Data Table"),
+ card_body(
+ tableOutput("dataTable")
+ )
+ ),
+
+ # Refresh Controls
+ actionButton("refresh", "Refresh Data", class = "btn-primary mt-3"),
+ verbatimTextOutput("refreshCount")
+ ),
+
+ # Panel 3: Settings
+ react_nav_panel(
+ "Settings",
+ icon = bs_icon("gear-fill"),
+ value = "settings",
+
+ # Preferences Card
+ card(
+ card_header("Preferences"),
+ card_body(
+ textInput("username", "Username:"),
+ checkboxInput("darkMode", "Dark Mode", FALSE),
+ checkboxInput("notifications", "Enable Notifications", TRUE)
+ )
+ ),
+
+ # Current Settings Card
+ card(
+ card_header("Current Settings"),
+ card_body(
+ verbatimTextOutput("currentSettings")
+ )
+ )
+ )
+ )
+)
+
+# Define server logic
+server <- function(input, output, session) {
+ # Sales Plot - reactive to months and region
+ output$salesPlot <- renderPlot({
+ # Generate cumulative sales data based on inputs
+ months <- seq_len(input$months)
+ set.seed(42) # For reproducibility
+ sales <- cumsum(runif(input$months, min = 50, max = 150))
+
+ # Create line plot
+ plot(
+ months,
+ sales,
+ type = "l",
+ lwd = 2,
+ col = "#2563eb",
+ main = paste("Sales Trend -", input$region),
+ xlab = "Month",
+ ylab = "Cumulative Sales ($1000s)",
+ las = 1
+ )
+
+ # Add points
+ points(months, sales, pch = 19, col = "#2563eb", cex = 1.2)
+
+ # Add grid
+ grid(col = "#e5e7eb", lty = 1)
+ })
+
+ # Data Table - showing mtcars data
+ output$dataTable <- renderTable(
+ {
+ head(mtcars[, 1:5], 10)
+ },
+ rownames = TRUE
+ )
+
+ # Refresh Counter - reactive value for button clicks
+ refreshCount <- reactiveVal(0)
+
+ observeEvent(input$refresh, {
+ refreshCount(refreshCount() + 1)
+ })
+
+ output$refreshCount <- renderText({
+ paste("Data refreshed", refreshCount(), "times")
+ })
+
+ # Current Settings Display
+ output$currentSettings <- renderPrint({
+ list(
+ username = if (nchar(input$username) > 0) input$username else "(not set)",
+ darkMode = input$darkMode,
+ notifications = input$notifications
+ )
+ })
+
+ observeEvent(input$darkMode, {
+ toggle_dark_mode(if (input$darkMode) "dark" else "light")
+ })
+}
+
+# Run the application
+shinyApp(ui = ui, server = server)
diff --git a/examples/9-blended/r/shinyreact.R b/examples/9-blended/r/shinyreact.R
new file mode 100644
index 0000000..1017802
--- /dev/null
+++ b/examples/9-blended/r/shinyreact.R
@@ -0,0 +1,174 @@
+library(shiny)
+
+page_bare <- function(..., title = NULL, lang = NULL) {
+ ui <- list(
+ shiny:::jqueryDependency(),
+ if (!is.null(title)) tags$head(tags$title(title)),
+ ...
+ )
+ attr(ui, "lang") <- lang
+ ui
+}
+
+page_react <- function(
+ ...,
+ title = NULL,
+ js_file = "main.js",
+ css_file = "main.css",
+ lang = "en"
+) {
+ page_bare(
+ title = title,
+ tags$head(
+ if (!is.null(js_file)) tags$script(src = js_file, type = "module"),
+ if (!is.null(css_file)) tags$link(href = css_file, rel = "stylesheet")
+ ),
+ tags$div(id = "root"),
+ ...
+ )
+}
+
+
+#' Reactively render arbitrary JSON object data.
+#'
+#' This is a generic renderer that can be used to render any Jsonifiable data.
+#' The data goes through shiny:::toJSON() before being sent to the client.
+render_json <- function(
+ expr,
+ env = parent.frame(),
+ quoted = FALSE,
+ outputArgs = list(),
+ sep = " "
+) {
+ func <- installExprFunction(
+ expr,
+ "func",
+ env,
+ quoted,
+ label = "render_json"
+ )
+
+ createRenderFunction(
+ func,
+ function(value, session, name, ...) {
+ value
+ },
+ function(...) {
+ stop("Not implemented")
+ },
+ outputArgs
+ )
+}
+
+#' Send a custom message to the client
+#'
+#' A convenience function for sending custom messages from the Shiny server to
+#' React components using useShinyMessageHandler() hook. This wraps messages in a
+#' standard format and sends them via the "shinyReactMessage" channel.
+#'
+#' When called from within a Shiny module, the message type is automatically
+#' namespaced using session$ns() to match the React component's namespace.
+#'
+#' @param session The Shiny session object
+#' @param type The message type (should match messageType in useShinyMessageHandler)
+#' @param data The data to send to the client
+post_message <- function(session, type, data) {
+ # Apply namespace to message type using session$ns()
+ # session$ns() returns the ID unchanged if not in a module context
+ namespaced_type <- session$ns(type)
+
+ session$sendCustomMessage(
+ "shinyReactMessage",
+ list(
+ type = namespaced_type,
+ data = data
+ )
+ )
+}
+
+#' Create a React navigation panel
+#'
+#' Wraps Shiny content for a navigation panel in a React sidebar layout.
+#' The panel content is initially hidden and shown by React when the panel
+#' is activated.
+#'
+#' @param title The display title for the navigation panel
+#' @param ... Shiny UI elements to display in the panel
+#' @param icon Optional icon for the panel. Can be bsicons::bs_icon() or
+#' fontawesome::fa() which renders to SVG string
+#' @param value The value/ID for this panel (defaults to title)
+#' @return A div tag with appropriate data attributes for React integration
+react_nav_panel <- function(title, ..., icon = NULL, value = title) {
+ # icon can be bsicons::bs_icon() or fontawesome::fa() - renders to SVG string
+ icon_svg <- if (!is.null(icon)) {
+ if (inherits(icon, "shiny.tag")) as.character(icon) else icon
+ } else {
+ NULL
+ }
+
+ div(
+ `data-slot` = value,
+ `data-panel-title` = title,
+ `data-panel-icon` = icon_svg,
+ class = "react-sidebar-panel-content",
+ style = "display: none;", # Hidden until React takes over
+ ...
+ )
+}
+
+#' Create a React sidebar layout
+#'
+#' Creates a custom element that combines React-based navigation with Shiny
+#' content panels. The sidebar navigation is rendered by React while panel
+#' content is pure Shiny UI.
+#'
+#' @param ... react_nav_panel() elements defining the sidebar panels
+#' @param id Optional ID for the layout container
+#' @param title Optional title to display in the sidebar header
+#' @param collapsible Whether the sidebar can be collapsed (default: TRUE)
+#' @param default_open Whether sidebar starts open (default: TRUE)
+#' @param position Sidebar position: "left" or "right" (default: "left")
+#' @param width Sidebar width when open (default: "250px")
+#' @return A custom element tag containing the sidebar layout
+react_sidebar_layout <- function(
+ ...,
+ id = NULL,
+ title = NULL,
+ collapsible = TRUE,
+ default_open = TRUE,
+ position = "left",
+ width = "250px"
+) {
+ panels <- list(...)
+
+ # Extract panel metadata for React
+ panel_config <- lapply(panels, function(p) {
+ list(
+ id = p$attribs$`data-slot`,
+ title = p$attribs$`data-panel-title`,
+ icon = p$attribs$`data-panel-icon`
+ )
+ })
+
+ tagList(
+ tags$head(
+ tags$script(src = "sidebar.js", type = "module"),
+ tags$link(href = "sidebar.css", rel = "stylesheet")
+ ),
+ tag(
+ "react-sidebar-layout",
+ list(
+ id = id,
+ `data-title` = title,
+ `data-panels` = jsonlite::toJSON(panel_config, auto_unbox = TRUE),
+ `data-collapsible` = tolower(as.character(collapsible)),
+ `data-default-open` = tolower(as.character(default_open)),
+ `data-position` = position,
+ `data-width` = width,
+ class = "react-sidebar-layout-container",
+ style = "display: flex; height: 100%; width: 100%;",
+ panels # Shiny content as children
+ )
+ )
+ )
+}
diff --git a/examples/9-blended/srcts/SidebarLayout.tsx b/examples/9-blended/srcts/SidebarLayout.tsx
new file mode 100644
index 0000000..4af7280
--- /dev/null
+++ b/examples/9-blended/srcts/SidebarLayout.tsx
@@ -0,0 +1,116 @@
+import { useState, useEffect, useRef } from "react";
+
+interface PanelConfig {
+ id: string;
+ title: string;
+ icon: string | null;
+}
+
+interface SidebarLayoutProps {
+ title: string | null;
+ panels: PanelConfig[];
+ collapsible: boolean;
+ defaultOpen: boolean;
+ position: 'left' | 'right';
+ width: string;
+ onPanelMount: (panelId: string, containerEl: HTMLElement | null) => void;
+}
+
+export function SidebarLayout({
+ title,
+ panels,
+ collapsible,
+ defaultOpen,
+ position,
+ width,
+ onPanelMount,
+}: SidebarLayoutProps) {
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+ const [activePanel, setActivePanel] = useState(panels[0]?.id || '');
+ const panelRefs = useRef>(new Map());
+ const mountedPanels = useRef>(new Set());
+
+ useEffect(() => {
+ panels.forEach(panel => {
+ if (!mountedPanels.current.has(panel.id)) {
+ const containerEl = panelRefs.current.get(panel.id);
+ if (containerEl) {
+ onPanelMount(panel.id, containerEl);
+ mountedPanels.current.add(panel.id);
+ }
+ }
+ });
+ }, [panels, onPanelMount]);
+
+ const toggleSidebar = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+
+ {title && (
+
+ {isOpen &&
{title} }
+ {collapsible && (
+
+
+ {position === 'left' ? (
+ isOpen ? (
+
+ ) : (
+
+ )
+ ) : (
+ isOpen ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ )}
+
+ )}
+
+ {panels.map(panel => (
+ setActivePanel(panel.id)}
+ title={!isOpen ? panel.title : undefined}
+ >
+ {panel.icon && (
+
+ )}
+ {isOpen && {panel.title} }
+
+ ))}
+
+
+
+ {panels.map(panel => (
+ {
+ panelRefs.current.set(panel.id, el);
+ }}
+ className={`sidebar-panel ${activePanel === panel.id ? 'active' : ''}`}
+ />
+ ))}
+
+
+ );
+}
diff --git a/examples/9-blended/srcts/main.tsx b/examples/9-blended/srcts/main.tsx
new file mode 100644
index 0000000..800ce5b
--- /dev/null
+++ b/examples/9-blended/srcts/main.tsx
@@ -0,0 +1,30 @@
+import { ShinyReactComponentElement } from "@posit/shiny-react";
+import { SidebarLayout } from "./SidebarLayout";
+import "./styles.css";
+
+interface PanelConfig {
+ id: string;
+ title: string;
+ icon: string | null;
+}
+
+class ReactSidebarLayoutElement extends ShinyReactComponentElement {
+ protected render() {
+ const config = this.getConfig();
+ return (
+
+ );
+ }
+}
+
+if (!customElements.get("react-sidebar-layout")) {
+ customElements.define("react-sidebar-layout", ReactSidebarLayoutElement);
+}
diff --git a/examples/9-blended/srcts/styles.css b/examples/9-blended/srcts/styles.css
new file mode 100644
index 0000000..a28dd00
--- /dev/null
+++ b/examples/9-blended/srcts/styles.css
@@ -0,0 +1,169 @@
+:root {
+ --sidebar-bg: var(--bs-secondary-bg, #f8f9fa);
+ --sidebar-text: var(--bs-body-color, #212529);
+ --sidebar-hover: var(--bs-tertiary-bg, #e9ecef);
+ --sidebar-active: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.15);
+ --sidebar-active-text: var(--bs-primary, #0d6efd);
+ --sidebar-border: var(--bs-border-color, #dee2e6);
+}
+
+.sidebar-layout {
+ display: flex;
+ height: 100vh;
+ width: 100%;
+ overflow: hidden;
+}
+
+.sidebar-layout[data-position="right"] {
+ flex-direction: row-reverse;
+}
+
+.sidebar {
+ background-color: var(--sidebar-bg);
+ color: var(--sidebar-text);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ transition: width 0.3s ease;
+ border-right: 1px solid var(--sidebar-border);
+ overflow: hidden;
+}
+
+.sidebar-layout[data-position="right"] .sidebar {
+ border-right: none;
+ border-left: 1px solid var(--sidebar-border);
+}
+
+.sidebar-header {
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--sidebar-border);
+ min-height: 60px;
+}
+
+.sidebar-layout[data-open="false"] .sidebar-header {
+ justify-content: center;
+}
+
+.sidebar-layout[data-open="false"] .sidebar-nav-item {
+ justify-content: center;
+ padding: 0.75rem 0;
+}
+
+.sidebar-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.sidebar-collapse-btn {
+ background: none;
+ border: none;
+ color: var(--sidebar-text);
+ cursor: pointer;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+ flex-shrink: 0;
+}
+
+.sidebar-collapse-btn:hover {
+ background-color: var(--sidebar-hover);
+}
+
+.sidebar-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.5rem;
+}
+
+.sidebar-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ background: none;
+ border: none;
+ color: var(--sidebar-text);
+ cursor: pointer;
+ border-radius: 6px;
+ transition: background-color 0.2s ease;
+ text-align: left;
+ width: 100%;
+ height: 44px;
+ box-sizing: border-box;
+}
+
+.sidebar-nav-item:hover {
+ background-color: var(--sidebar-hover);
+}
+
+.sidebar-nav-item.active {
+ background-color: var(--sidebar-active);
+ color: var(--sidebar-active-text);
+}
+
+.sidebar-nav-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.sidebar-nav-icon svg {
+ width: 20px;
+ height: 20px;
+ fill: currentColor;
+}
+
+.sidebar-nav-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 1;
+ transition: opacity 0.3s ease;
+}
+
+.sidebar-layout[data-open="false"] .sidebar-nav-label {
+ opacity: 0;
+ width: 0;
+}
+
+.sidebar-content {
+ flex: 1;
+ overflow: auto;
+ position: relative;
+ background-color: var(--bs-body-bg, #fff);
+}
+
+.sidebar-panel {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ overflow: auto;
+ padding: 1rem;
+ visibility: hidden;
+ z-index: 0;
+ pointer-events: none;
+}
+
+.sidebar-panel.active {
+ visibility: visible;
+ z-index: 1;
+ pointer-events: auto;
+ background-color: var(--bs-body-bg, #fff);
+}
diff --git a/examples/9-blended/tsconfig.json b/examples/9-blended/tsconfig.json
new file mode 100644
index 0000000..d788f15
--- /dev/null
+++ b/examples/9-blended/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "es2022",
+ "noEmit": true,
+ "moduleResolution": "node",
+ "lib": ["es2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./srcts/*"]
+ }
+ },
+ "include": ["srcts/**/*.ts", "srcts/**/*.tsx"]
+}
diff --git a/src/ImageOutput.tsx b/src/ImageOutput.tsx
index a791e35..791a3f6 100644
--- a/src/ImageOutput.tsx
+++ b/src/ImageOutput.tsx
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useShinyInput, useShinyOutput } from "./use-shiny";
import { createDebouncedFn } from "./utils";
+import {
+ applyNamespace,
+ useShinyModuleNamespace,
+} from "./ShinyModuleContext";
export type ImageData = {
src: string;
@@ -71,6 +75,8 @@ export type ImageData = {
* @param props.onRecalculating - Optional callback function that gets called
* whenever the recalculation status changes. Receives a boolean indicating
* whether the image is currently recalculating.
+ * @param props.namespace - Optional namespace prefix for Shiny module support.
+ * If provided, the ID will be prefixed as `${namespace}-${id}`.
*
* @remarks
* The component automatically:
@@ -135,6 +141,7 @@ export function ImageOutput({
height,
debounceMs = 400,
onRecalculating,
+ namespace: explicitNamespace,
}: {
id: string;
className?: string;
@@ -142,22 +149,31 @@ export function ImageOutput({
height?: string;
debounceMs?: number;
onRecalculating?: (isRecalculating: boolean) => void;
+ namespace?: string;
}) {
+ // Apply namespace from context or explicit option
+ const contextNamespace = useShinyModuleNamespace();
+ const namespace = explicitNamespace ?? contextNamespace;
+ const namespacedId = applyNamespace(id, namespace);
+
const [imgWidth, setImgWidth] = useShinyInput(
- ".clientdata_output_" + id + "_width",
+ `.clientdata_output_${namespacedId}_width`,
null,
);
const [imgHeight, setImgHeight] = useShinyInput(
- ".clientdata_output_" + id + "_height",
+ `.clientdata_output_${namespacedId}_height`,
null,
);
// Track if the image is hidden
const [imgHidden] = useShinyInput(
- ".clientdata_output_" + id + "_hidden",
+ `.clientdata_output_${namespacedId}_hidden`,
false,
);
- const [imgData, imgRecalculating] = useShinyOutput(id, undefined);
+ const [imgData, imgRecalculating] = useShinyOutput(
+ namespacedId,
+ undefined,
+ );
// Create a reference to the img element to access its properties
const imgRef = useRef(null);
diff --git a/src/ShinyModuleContext.tsx b/src/ShinyModuleContext.tsx
new file mode 100644
index 0000000..419d818
--- /dev/null
+++ b/src/ShinyModuleContext.tsx
@@ -0,0 +1,59 @@
+import { createContext, useContext, type ReactNode } from "react";
+
+const ShinyModuleContext = createContext(null);
+
+export interface ShinyModuleProviderProps {
+ namespace: string;
+ children: ReactNode;
+}
+
+/**
+ * Provides a namespace context for Shiny module support.
+ *
+ * All child components using useShinyInput, useShinyOutput, or
+ * useShinyMessageHandler will automatically have their IDs prefixed
+ * with the provided namespace.
+ *
+ * Note: This provider does NOT support nesting. If you need nested modules,
+ * pass the full namespace string (e.g., "outer-inner") directly.
+ *
+ * @param namespace The complete namespace string to apply to child hooks.
+ * @param children React children that will receive the namespace context.
+ *
+ * @example
+ * ```tsx
+ *
+ * {/* useShinyInput("x") becomes "myModule-x" *}
+ *
+ * ```
+ */
+export function ShinyModuleProvider({
+ namespace,
+ children,
+}: ShinyModuleProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access the current module namespace from context.
+ * Returns null if not within a ShinyModuleProvider.
+ */
+export function useShinyModuleNamespace(): string | null {
+ return useContext(ShinyModuleContext);
+}
+
+/**
+ * Utility function to apply namespace to an ID.
+ * If namespace is provided, returns `${namespace}-${id}`.
+ * Otherwise returns the original id.
+ */
+export function applyNamespace(id: string, namespace: string | null): string {
+ if (namespace) {
+ return `${namespace}-${id}`;
+ }
+ return id;
+}
diff --git a/src/ShinyReactComponentElement.tsx b/src/ShinyReactComponentElement.tsx
new file mode 100644
index 0000000..3a12c1a
--- /dev/null
+++ b/src/ShinyReactComponentElement.tsx
@@ -0,0 +1,218 @@
+import { createRoot, type Root } from "react-dom/client";
+import { ShinyModuleProvider } from "./ShinyModuleContext";
+
+/**
+ * Base class for creating custom elements that render React components
+ * with automatic Shiny integration.
+ *
+ * Features:
+ * - Automatic namespace support via ShinyModuleProvider (uses element's id)
+ * - Slot preservation for blended React + Shiny content
+ * - Config parsing from data-* attributes (with JSON auto-parsing)
+ * - Proper Shiny binding lifecycle (bindAll/unbindAll)
+ * - Default slot capture: When no [data-slot] elements are found, all children
+ * are captured under the reserved slot name "__children__"
+ *
+ * @example Simple widget (no slots):
+ * ```typescript
+ * class MyCounterElement extends ShinyReactComponentElement {
+ * static component = CounterWidget;
+ * }
+ * customElements.define('my-counter', MyCounterElement);
+ * ```
+ *
+ * @example Blended component (with slots):
+ * ```typescript
+ * class MySidebarElement extends ShinyReactComponentElement {
+ * static component = SidebarLayout;
+ *
+ * protected render() {
+ * return ;
+ * }
+ * }
+ * customElements.define('my-sidebar', MySidebarElement);
+ * ```
+ *
+ * @example Skip clearing innerHTML (rare):
+ * ```typescript
+ * class MyOverlayElement extends ShinyReactComponentElement {
+ * protected clearContent() {} // no-op
+ * }
+ * ```
+ */
+export class ShinyReactComponentElement extends HTMLElement {
+ protected root: Root | null = null;
+ protected slotContents: Map = new Map();
+
+ /**
+ * The React component to render. Set this on your subclass.
+ * @example
+ * ```typescript
+ * class MyElement extends ShinyReactComponentElement {
+ * static component = MyReactComponent;
+ * }
+ * ```
+ */
+ static component: React.ComponentType> | null = null;
+
+ /**
+ * Captures children with [data-slot] attribute, storing their contents
+ * keyed by slot name. Called automatically in connectedCallback.
+ *
+ * If no [data-slot] elements are found and the element has child nodes,
+ * all children are captured under the reserved slot name "__children__".
+ * This provides a default slot for components that don't use named slots.
+ *
+ * @param selector CSS selector for slot containers (default: '[data-slot]')
+ * @returns Map of slot names to their child nodes
+ */
+ protected captureSlots(
+ selector: string = "[data-slot]",
+ ): Map {
+ const elements = this.querySelectorAll(selector);
+ if (elements.length === 0) {
+ // No named slots - capture all children as default slot
+ if (this.childNodes.length > 0) {
+ this.slotContents.set("__children__", Array.from(this.childNodes));
+ }
+ } else {
+ // Named slots found - capture each one
+ elements.forEach((el) => {
+ const slotName = el.getAttribute("data-slot");
+ if (slotName) {
+ this.slotContents.set(slotName, Array.from(el.childNodes));
+ }
+ });
+ }
+ return this.slotContents;
+ }
+
+ /**
+ * Moves captured slot content into a container element and initializes
+ * Shiny bindings. Call this from your React component via onSlotMount callback.
+ *
+ * @param slotName The slot identifier (from data-slot attribute)
+ * @param container The DOM element to move content into
+ */
+ protected async mountSlot(
+ slotName: string,
+ container: HTMLElement | null,
+ ): Promise {
+ const content = this.slotContents.get(slotName);
+ if (content && container) {
+ content.forEach((node) => container.appendChild(node));
+ await window.Shiny?.bindAll?.(container);
+ }
+ }
+
+ /**
+ * Bound callback for mounting slots. Pass this to your React component
+ * to handle slot content mounting.
+ *
+ * @example
+ * ```typescript
+ * protected render() {
+ * return ;
+ * }
+ * ```
+ */
+ protected get onSlotMount(): (
+ slotName: string,
+ el: HTMLElement | null,
+ ) => Promise {
+ return this.mountSlot.bind(this);
+ }
+
+ /**
+ * Converts data-* attributes to a props object.
+ * Automatically attempts JSON parsing for rich values (numbers, booleans,
+ * arrays, objects). Falls back to string if parsing fails.
+ *
+ * @returns Config object with parsed data attributes
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * Results in: { count: 5, enabled: true, items: [1,2,3], title: "Hello" }
+ */
+ protected getConfig(): Record {
+ const config: Record = {};
+ for (const [key, value] of Object.entries(this.dataset)) {
+ if (value === undefined) continue;
+ try {
+ config[key] = JSON.parse(value);
+ } catch {
+ config[key] = value;
+ }
+ }
+ return config;
+ }
+
+ /**
+ * The namespace for Shiny module support, derived from the element's id.
+ * Returns undefined if no id is set.
+ */
+ protected get namespace(): string | undefined {
+ return this.id || undefined;
+ }
+
+ /**
+ * Renders the React component. Override this to customize rendering,
+ * pass additional props, or wrap in providers.
+ *
+ * @returns React node to render
+ */
+ protected render(): React.ReactNode {
+ const Component = (this.constructor as typeof ShinyReactComponentElement)
+ .component;
+ if (!Component) {
+ console.error(`${this.constructor.name}: No static component defined`);
+ return null;
+ }
+ return ;
+ }
+
+ /**
+ * Wraps content in ShinyModuleProvider if namespace exists.
+ */
+ private wrapWithProvider(content: React.ReactNode): React.ReactNode {
+ if (this.namespace) {
+ return (
+
+ {content}
+
+ );
+ }
+ return content;
+ }
+
+ /**
+ * Clears the element's innerHTML before React renders.
+ * Override with a no-op if you need to preserve existing content.
+ */
+ protected clearContent(): void {
+ this.innerHTML = "";
+ }
+
+ /**
+ * Called when the element is added to the DOM.
+ * Captures slots, clears content, and renders the React component.
+ */
+ connectedCallback(): void {
+ this.captureSlots();
+ this.clearContent();
+ this.root = createRoot(this);
+ this.root.render(this.wrapWithProvider(this.render()));
+ }
+
+ /**
+ * Called when the element is removed from the DOM.
+ * Unbinds Shiny and unmounts the React root.
+ */
+ disconnectedCallback(): void {
+ window.Shiny?.unbindAll?.(this);
+ this.root?.unmount();
+ this.root = null;
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 7705f57..5c6f328 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,12 +4,17 @@ import { type ShinyMessageRegistry } from "./message-registry";
import { type ShinyReactRegistry } from "./react-registry";
export { ImageOutput } from "./ImageOutput";
+export { ShinyReactComponentElement } from "./ShinyReactComponentElement";
export {
useShinyInitialized,
useShinyInput,
useShinyMessageHandler,
useShinyOutput,
} from "./use-shiny";
+export {
+ ShinyModuleProvider,
+ useShinyModuleNamespace,
+} from "./ShinyModuleContext";
export type ShinyClassExtended = ShinyClass & {
reactRegistry: ShinyReactRegistry;
diff --git a/src/use-shiny.ts b/src/use-shiny.ts
index b3a4d13..dddf605 100644
--- a/src/use-shiny.ts
+++ b/src/use-shiny.ts
@@ -7,6 +7,10 @@ import { type InputRegistryEntry } from "./input-registry";
import { initializeMessageRegistry } from "./message-registry";
import { createReactOutputBinding } from "./output-registry";
import { getReactRegistry, initializeReactRegistry } from "./react-registry";
+import {
+ applyNamespace,
+ useShinyModuleNamespace,
+} from "./ShinyModuleContext";
/**
* A React hook for managing a Shiny input value.
@@ -34,6 +38,8 @@ import { getReactRegistry, initializeReactRegistry } from "./react-registry";
* (default: 100).
* @param options.priority Priority level for the input event (from Shiny's
* EventPriority enum).
+ * @param options.namespace Optional namespace prefix for Shiny module support.
+ * If provided, the ID will be prefixed as `${namespace}-${id}`.
* @returns A tuple containing the current value and a function to set the
* value: `[value, setValue]`.
*/
@@ -43,13 +49,20 @@ export function useShinyInput(
{
debounceMs = 100,
priority,
+ namespace: explicitNamespace,
}: {
debounceMs?: number;
priority?: EventPriority;
+ namespace?: string;
} = {},
): [T, (value: T) => void] {
ensureShinyReactInitialized();
+ // Apply namespace from context or explicit option
+ const contextNamespace = useShinyModuleNamespace();
+ const namespace = explicitNamespace ?? contextNamespace;
+ const namespacedId = applyNamespace(id, namespace);
+
// NOTE: It's a little odd that debounceMs and priority passed this way; the
// debounceMs is associated with the specific input name, and in Shiny's API,
// priority is associated with each individual call to setInputValue(). But
@@ -60,7 +73,7 @@ export function useShinyInput(
let startValue: T = defaultValue;
const reactRegistry = getReactRegistry();
const inputRegistryEntry = reactRegistry.inputs.get(
- id,
+ namespacedId,
) as InputRegistryEntry;
if (inputRegistryEntry) {
@@ -85,7 +98,7 @@ export function useShinyInput(
// Make sure the input registry entry exists for this Shiny input ID
const reactRegistry = getReactRegistry();
const inputRegistryEntry = reactRegistry.inputs.getOrCreate(
- id,
+ namespacedId,
defaultValue,
);
@@ -109,7 +122,7 @@ export function useShinyInput(
// useEffect will be called again. If someone wants to really get rid of
// the registry entry, they will have to do so manually.
};
- }, [id, shinyInitialized, debounceMs, priority, defaultValue]);
+ }, [namespacedId, shinyInitialized, debounceMs, priority, defaultValue]);
const setValueWrapped = useCallback(
(value: T) => {
@@ -118,14 +131,14 @@ export function useShinyInput(
// }
const reactRegistry = getReactRegistry();
- const inputRegistryEntry = reactRegistry.inputs.get(id);
+ const inputRegistryEntry = reactRegistry.inputs.get(namespacedId);
if (!inputRegistryEntry) {
console.error(`Input ${id} not found`);
return;
}
inputRegistryEntry.setValue(value);
},
- [id],
+ [namespacedId, id],
);
return [value, setValueWrapped];
@@ -139,6 +152,9 @@ export function useShinyInput(
* @param outputId The ID of the Shiny output to subscribe to.
* @param defaultValue Optional default value to use before the first server
* update.
+ * @param options Optional configuration object.
+ * @param options.namespace Optional namespace prefix for Shiny module support.
+ * If provided, the outputId will be prefixed as `${namespace}-${outputId}`.
* @returns A tuple containing [value, recalculating] where:
* - value: The current value of the Shiny output
* - recalculating: Boolean indicating if the server is currently
@@ -147,6 +163,11 @@ export function useShinyInput(
export function useShinyOutput(
outputId: string,
defaultValue: T | undefined = undefined,
+ {
+ namespace: explicitNamespace,
+ }: {
+ namespace?: string;
+ } = {},
): [T | undefined, boolean] {
const [value, setValue] = useState(defaultValue);
const [recalculating, setRecalculating] = useState(false);
@@ -154,17 +175,22 @@ export function useShinyOutput(
ensureShinyReactInitialized();
+ // Apply namespace from context or explicit option
+ const contextNamespace = useShinyModuleNamespace();
+ const namespace = explicitNamespace ?? contextNamespace;
+ const namespacedOutputId = applyNamespace(outputId, namespace);
+
useEffect(() => {
if (!shinyInitialized) {
return;
}
const reactRegistry = getReactRegistry();
- reactRegistry.outputs.add(outputId, setValue, setRecalculating);
+ reactRegistry.outputs.add(namespacedOutputId, setValue, setRecalculating);
return () => {
- reactRegistry.outputs.remove(outputId);
+ reactRegistry.outputs.remove(namespacedOutputId);
};
- }, [outputId, shinyInitialized]);
+ }, [namespacedOutputId, shinyInitialized]);
return [value, recalculating];
}
@@ -191,17 +217,30 @@ export function useShinyOutput(
* @param messageType The type/name of the custom message to listen for.
* @param handler The function to call when a message of this type is received.
* The handler receives the message data as its parameter.
+ * @param options Optional configuration object.
+ * @param options.namespace Optional namespace prefix for Shiny module support.
+ * If provided, the messageType will be prefixed as `${namespace}-${messageType}`.
*/
export function useShinyMessageHandler(
messageType: string,
handler: (data: T) => void,
+ {
+ namespace: explicitNamespace,
+ }: {
+ namespace?: string;
+ } = {},
): void {
const shinyInitialized = useShinyInitialized();
ensureShinyReactInitialized();
+ // Apply namespace from context or explicit option
+ const contextNamespace = useShinyModuleNamespace();
+ const namespace = explicitNamespace ?? contextNamespace;
+ const namespacedMessageType = applyNamespace(messageType, namespace);
+
useEffect(() => {
- if (!shinyInitialized || !messageType || !handler) {
+ if (!shinyInitialized || !namespacedMessageType || !handler) {
return;
}
const shiny = getShiny();
@@ -210,14 +249,14 @@ export function useShinyMessageHandler(
}
// Register the message handler with our dedicated message registry
- shiny.messageRegistry.addHandler(messageType, handler);
+ shiny.messageRegistry.addHandler(namespacedMessageType, handler);
// Cleanup function that removes the handler when component unmounts
// or when messageType/handler changes
return () => {
- shiny.messageRegistry.removeHandler(messageType, handler);
+ shiny.messageRegistry.removeHandler(namespacedMessageType, handler);
};
- }, [shinyInitialized, messageType, handler]);
+ }, [shinyInitialized, namespacedMessageType, handler]);
}
/**