Wrapping React Apps¶
Skill version 2026.08.13
Make an existing React front-end be the Panel app: one ReactComponent
renders the entire UI from a .jsx file, and Panel components (chat, tables,
plots) are embedded into its JSX tree as children. Params are the only wire
between them.
This is the opposite direction from Converting Designs to Material UI, which rebuilds a React UI in pmui. For the component-type decision ladder and CDN rules, see Building Custom Components — this document is only about the whole-app case.
Contents¶
- When This Shape Is Right
- Two Classes: Shell and App
- Params Are the Only Transport
- Embedding Panel Components as Children
- Request/Response Param Pairs
- Shadow DOM and Stylesheets
- The
_importmapQuery-String Trap - Compiling the Bundle
- Owning the Whole Page
- Key DOs and DON'Ts
When This Shape Is Right¶
Reach for it only when the React UI is the asset and Python is the service layer:
- A substantial React/JSX UI already exists — thousands of lines, bespoke CSS, many views — and rebuilding it in pmui is the larger job.
- The UI is mostly chrome: navigation, modals, cards, toasts, filters. The parts that need Python are few and well-bounded (a chat, a data table, a plot).
- You need CSS control that Material's theme system doesn't give you.
Rebuild in pmui instead when the design is mostly widgets pmui already ships, or
when nearly every interaction needs Python. Every param is a websocket round
trip, so a UI where Python must respond to each click is slower and more code
this way than in pmui. The failure mode is choosing the React shell for a
dashboard and then re-implementing Select, Tabs, and DataFrame in JSX.
Two Classes: Shell and App¶
Split the Python side in two. The ReactComponent is a declarative param
surface that mirrors the JSX and holds no logic; a Viewer owns the data, the
watchers, and the async work.
import pathlib
import panel as pn
import panel_material_ui as pmui
import param
from panel.custom import Child, ReactComponent
class Shell(ReactComponent):
"""Param surface only — no watchers, no IO."""
data = param.Dict(default={})
current_view = param.Selector(default="Dashboard", objects=["Dashboard", "Detail"])
theme = param.Selector(default="dark", objects=["dark", "light"])
favorites = param.List(default=[])
chat = Child()
_esm = "shell.jsx"
_stylesheets = ["shell.css"]
_bundle = pathlib.Path(__file__).parent / "shell.bundle.js"
class App(pn.viewable.Viewer):
def __init__(self, **params):
super().__init__(**params)
self._chat = pmui.ChatInterface(callback=self._chat_callback, sizing_mode="stretch_both")
self.shell = Shell(data=load_data(), chat=self._chat, sizing_mode="stretch_both")
self.shell.param.watch(self._persist, ["theme", "favorites"])
def __panel__(self):
return self.shell
Keeping them separate pays off three ways: the shell class stays readable as the contract the JSX consumes, side effects live somewhere testable without a browser, and the shell can be driven by a different data source (fixtures, a second app) without touching it.
Params Are the Only Transport¶
model.useState("param_name") gives a React-hook-shaped tuple wired
bidirectionally to the Python param. A whole-app shell needs nothing else — no
custom messages, no _handle_msg, no events.
export function render({ model, view }) {
const [data] = model.useState("data"); // Python owns it
const [view_, setView] = model.useState("current_view"); // both sides write
const [menuOpen, setMenuOpen] = React.useState(false); // UI-only, stays local
...
}
Two rules that matter more at app scale than for a single widget:
- Don't import React. Panel provides it as a global. An
import React from "react"line resolves to a second copy and hooks throw Invalid hook call. - Only promote state to a param if Python needs it. Hover state, which
accordion is open, a dropdown's open flag — these belong in
React.useState. Everymodel.useStateparam costs a round trip and a re-render on change, so a shell that putsmenuOpenon the Python class pays network latency to open a menu. Expect roughly a 2:1 split of local to synced state.
Embedding Panel Components as Children¶
Declare Child() (one) or Children() (a list) and render with
model.get_child, which returns a React element you can place in JSX:
The child is a real Panel component with real Python callbacks — a
pmui.ChatInterface with an async streaming callback, a Tabulator with
add_filter, a HoloViews pane. Give children sizing_mode="stretch_both" and a
CSS container with an explicit height; a child in an auto-height flex box
collapses to nothing.
There is also an escape hatch for writing straight to a child's Bokeh model from JS, bypassing params:
Use it only to drive a hidden control you deliberately mounted — the standard
case being a pmui.ThemeToggle(visible=False) so pmui's own theming follows the
shell's theme switch. It is unvalidated and untyped, so anything you can express
as a param should be one.
Request/Response Param Pairs¶
React cannot call Python. Model each call as a request param the JSX writes and a response param it reads:
def _on_suggest(self, event):
payload = (event.new or "").strip()
if not payload:
return
self.shell.suggest_request = "" # reset so an identical repeat re-fires
pn.state.execute(partial(self._run_suggest, payload))
async def _run_suggest(self, payload):
self.shell.suggest_response = await self._llm.suggest(payload)
Both lines in that watcher are load-bearing:
- Reset the request param immediately. Params fire watchers only on change, so submitting the same request twice in a row is silently a no-op unless you clear it. This is the usual cause of "it works once".
- Don't do async work in the watcher. Watchers are synchronous; awaiting
there blocks the session.
pn.state.executeschedules the coroutine on the session's event loop so the UI stays live.
For long operations add a third param the Python side writes progress into
(sync_message, sync_report) and render it in React — that's how you get a
progress bar without a second channel.
Shadow DOM and Stylesheets¶
ReactComponent.use_shadow_dom defaults to True, and it only takes effect
when the parent is not another React component — which is exactly the
whole-app case. Consequences that cost the most debugging time:
- Page-level CSS does not reach inside. Every stylesheet the shell needs
must be in
_stylesheets(local path or CDN URL).pn.config.raw_cssand<style>blocks in the template do nothing for shell markup. - Libraries that inject CSS into
document.headat runtime render unstyled, because the head is outside the shadow root. FontAwesome is the common case: disable its auto-injection and load the stylesheet explicitly.
import { config, library } from "@fortawesome/fontawesome-svg-core";
config.autoAddCss = false; // its runtime <style> can't reach into the shadow root
library.add(/* ...icons */);
_stylesheets = [
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/svg-with-js.min.css",
"shell.css",
]
document.body escapes your CSS. Render modals,
dropdowns, and toasts inside the component tree rather than through
ReactDOM.createPortal, or they appear unstyled.
- document.querySelector won't find your own nodes. Use refs.
The _importmap Query-String Trap¶
Panel appends ?deps=react@<v>,react-dom@<v>&external=react,react-dom to every
esm.sh URL in _importmap that has no ? of its own. Add any query string and
you opt out of that entirely — so you must declare the externals yourself, or
the package ships its own React and every hook throws Invalid hook call.
_importmap = {
"imports": {
# ✅ no "?" — Panel adds deps + external=react automatically
"@fortawesome/fontawesome-svg-core": "https://esm.sh/@fortawesome/fontawesome-svg-core@6.7.2",
# ❌ has a "?", so the auto-suffix is skipped — this bundles a second React
"@fortawesome/react-fontawesome": "https://esm.sh/@fortawesome/react-fontawesome@3.1.1?bundle",
# ✅ opted out, so externals are declared explicitly — including peer deps
"@fortawesome/react-fontawesome": (
"https://esm.sh/@fortawesome/react-fontawesome@3.1.1"
"?external=react,@fortawesome/fontawesome-svg-core"
),
}
}
List every shared peer dependency in external=, not just React. Two copies of
an icon library means library.add() populates one registry while the components
read the other, and icons silently don't render.
Compiling the Bundle¶
_bundle points at precompiled esbuild output. Which of _bundle and _esm
actually gets served depends on the serve mode, and this asymmetry is the trap:
| Serve mode | What's served |
|---|---|
panel serve --dev (sets config.autoreload) |
Raw .jsx, transpiled in-browser, deps fetched from CDN per _importmap. _bundle is ignored. |
panel serve (production) |
_bundle. _esm and _importmap are ignored. |
So editing the .jsx and verifying with --dev looks perfect while production
keeps serving the stale bundle. Recompile before every deploy, and treat the
bundle as a reviewable build artifact.
- Requires
node,npm, andesbuildonPATH. - Writes to
_bundlewhen set; otherwise<ClassName>.bundle.jsbeside the module. - It imports the module, so module-level code must be import-safe. Guard app
construction with
if __name__.startswith("bokeh"):rather than running it at import time. - esbuild bundles with no externals, so React ends up inside the bundle (expect a few hundred KB) and nothing is fetched from a CDN at runtime.
- Version pins in
_importmapURLs become the npm dependency pins — a URL without a version resolves tolatest, so pin deliberately. --unminifiedto read the output,--build-dirplus--skip-npmto recompile without reinstallingnode_modules.
Owning the Whole Page¶
A shell like this is the page and should not be wrapped in a template. Set the
document up in server_doc:
from panel_material_ui.base import BASE_TEMPLATE
class Shell(ReactComponent):
...
def server_doc(self, doc=None, title=None, location=True):
doc = super().server_doc(doc, title, location)
doc.title = title or "My App"
doc.template = BASE_TEMPLATE
doc.template_variables["is_page"] = True
doc.template_variables["favicon"] = "https://example.com/favicon.ico"
return doc
is_page=True marks <html data-theme-managed="true"> and suppresses Panel's
bundled theme stylesheet, handing theming to the component. pmui components do
this for themselves when a ThemeToggle is in the tree, but a plain
ReactComponent inherits Panel's server_doc and must opt in. Skipping it
leaves Panel's theme CSS fighting your own.
Pair it with sizing_mode="stretch_both" on the shell instance and serve with
the usual __name__ guard:
Key DOs and DON'Ts¶
- DO keep the shell class a pure param surface and put watchers on a separate
Viewer. - DO clear request params inside their watcher, and schedule async work with
pn.state.execute. - DO recompile the bundle before deploying, and check the compiled artifact into review.
- DON'T import React, or add a query string to an
esm.shURL without also passingexternal=react. - DON'T promote UI-only state (hover, open/closed) to a Python param.
- DON'T assume page CSS reaches the shell — shadow DOM is on, so use
_stylesheets. - DON'T choose this shape for a UI that pmui components already cover.