Designing Panel Architecture¶
Skill version 1.0.2
How to build a Panel app that survives growth: composing the code into focused classes (design-time), and how the app behaves once served to real, multi-user load (runtime — sessions, state, caching, threading). The Viewer Class Pattern covers a single Viewer; this picks up where one class stops being enough.
Contents¶
Composition:
- When to Reach for This
- The Composition Pattern
- Cross-Object Dependencies
- The
from_dataFactory - Sharing Derived Data
- Reactive Expressions (
pn.rx) - Computed Read-Only Params
- Wiring Shortcuts
- Imperative vs Declarative
Runtime and scale:
- The Session Model
- Server-Side State and Scheduling
- Streaming with Generators
- Caching
- Automatic Threading
- Profiling
- Batching, Loading, and Memory
When to Reach for This¶
- Two or more views need the same filtered/derived data — don't recompute it in each.
- State (filter values, selection) is read and written by several components.
- A single
Vieweris growing past ~150 lines or mixing data transforms with layout.
If none of these hold, stay with one Viewer.
The Composition Pattern¶
Four roles, each its own class, wired by param.ClassSelector:
- State (
Filters) — parameters only, plus its own widget panel. No data logic. - DataStore — holds the raw
dataand a reference to the state; exposes derived data. No UI. - View subclasses — presentation only; each reads from the shared
DataStore. - App — the shell that composes views and wraps them in
pmui.Page.
import panel as pn
import panel_material_ui as pmui
import param
pn.extension("tabulator", throttled=True)
class Filters(pn.viewable.Viewer):
year = param.Range(default=(2010, 2020), bounds=(2000, 2025))
manufacturers = param.ListSelector(default=[], objects=[])
def __panel__(self):
return pn.Param(
self,
parameters=["year", "manufacturers"],
widgets={"manufacturers": {"type": pmui.MultiChoice}},
width=320,
)
class DataStore(param.Parameterized): # plain Parameterized — no UI
data = param.DataFrame()
filters = param.ClassSelector(class_=Filters)
@param.depends("data", "filters.year", "filters.manufacturers")
def filtered(self):
low, high = self.filters.year
mask = self.data["year"].between(low, high)
if self.filters.manufacturers:
mask &= self.data["manufacturer"].isin(self.filters.manufacturers)
return self.data.loc[mask]
class View(pn.viewable.Viewer): # shared base
data_store = param.ClassSelector(class_=DataStore)
class Indicators(View):
@param.depends("data_store.filtered")
def __panel__(self):
df = self.data_store.filtered()
return pn.indicators.Number(name="Rows", value=len(df), format="{value:,.0f}")
class Table(View):
def __panel__(self):
return pn.widgets.Tabulator(
self.data_store.filtered, pagination="remote", page_size=12,
sizing_mode="stretch_width",
)
class App(pn.viewable.Viewer):
title = param.String(default="Wind Explorer")
data_store = param.ClassSelector(class_=DataStore)
views = param.List()
def __panel__(self):
main = pn.Column(*(view(data_store=self.data_store) for view in self.views))
if pn.state.served:
page = pmui.Page(title=self.title)
page.sidebar.append(self.data_store.filters)
page.main.append(main)
return page
return pn.Row(self.data_store.filters, main)
# Wire it up
df = make_data()
store = DataStore(data=df, filters=Filters.from_data(df))
App(data_store=store, views=[Indicators, Table]).servable()
The payoff: DataStore.filtered() is testable without rendering any UI, and one DataStore feeds every view — add a view by appending a class to views, nothing else.
Cross-Object Dependencies¶
@param.depends accepts dotted paths to a sub-object's parameter, so a DataStore method can depend on its Filters' params:
A view then depends on the store's method by name: @param.depends("data_store.filtered"). This is what lets state, transform, and view live in separate classes yet stay reactive. The objects must be linked via ClassSelector (or passed at construction) for the path to resolve.
The from_data Factory¶
Configure .objects/.bounds from data outside __init__ with a classmethod, so the state class stays construction-agnostic and testable. Note the idiom: after setting .objects/.bounds, assign the value to match — otherwise the default falls outside the new options/bounds.
@classmethod
def from_data(cls, df):
f = cls()
f.param.year.bounds = (int(df["year"].min()), int(df["year"].max()))
f.year = f.param.year.bounds # assign value to match bounds
f.param.manufacturers.objects = sorted(df["manufacturer"].unique().tolist())
f.manufacturers = f.param.manufacturers.objects # ...and objects
return f
Sharing Derived Data¶
Two ways to expose derived data from a DataStore:
- Method +
@param.depends(above) — recomputed each time a consumer calls it. Simple; fine when one or two views read it. - Stored
param.DataFrame— when many components read the same derived frame, compute it once into a param with awatch=True, on_init=Truewatcher and have views depend on that param. Avoids recomputing the filter for every consumer.
class DataStore(param.Parameterized):
data = param.DataFrame()
filters = param.ClassSelector(class_=Filters)
filtered = param.DataFrame() # cached result, shared
@param.depends("data", "filters.year", "filters.manufacturers",
watch=True, on_init=True)
def _update_filtered(self):
...
self.filtered = self.data.loc[mask]
Reactive Expressions (pn.rx)¶
For declarative data pipelines — as opposed to pn.bind, which wires inputs to a function — pn.rx wraps a value (often a DataFrame) so ordinary operations build a reactive expression. Widgets become reactive refs via widget.rx(). Full guide: Param's Reactive Expressions; the idioms below are the ones agents miss:
rxdf = pn.rx(turbines)
view = rxdf[rxdf.p_year.between(*year.rx()) & rxdf.p_cap.between(*capacity.rx())][cols]
pn.widgets.Tabulator(view, pagination="remote", page_size=5)
Useful .rx methods (call a plain function or builtin reactively without unwrapping):
expr.rx.pipe(fn)— apply an arbitrary function:df.t_manu.unique().rx.pipe(list).expr.rx.where(a, b)— reactive ternary.expr.rx.len()— reactivelen().pn.rx("# Hello {}").format(widget.param.value)— reactive string formatting.
Reach for pn.rx when the transform reads naturally as an expression; reach for pn.bind/@param.depends when it's a function or method.
Computed Read-Only Params¶
Mark a derived parameter constant=True so external code can't write it, then update it only inside param.edit_constant:
class Calculator(param.Parameterized):
left = param.Number(default=1)
op = param.Selector(default="+", objects=["+", "-", "*", "/"])
result = param.Number(default=0, constant=True) # read-only to the outside
@param.depends("left", "op", watch=True, on_init=True)
def _calculate(self):
with param.edit_constant(self):
self.result = ...
Wiring Shortcuts¶
Beyond .from_param() (see Using Material UI for which widget matches each Param type):
- Pass a
Parameteror widget directly as a constructor arg to bind it reactively — no callback:
card = pmui.Card(title=state.param.title, visible=state.param.visible)
table = pn.widgets.Tabulator(df, page_size=self.param.page_size)
widget.link(target, value="param_name")for one-way sync into another object's param when you only need one direction (cleaner than a manual.param.watch):
Imperative vs Declarative¶
Default to declarative (@param.depends, pn.bind, pn.rx) for data/UI derivations — easier to test and compose. Keep imperative .param.watch only for true side effects: logging, persisting settings, notifications.
def log_filter_change(event):
print(f"[filters] {event.name} -> {event.new}")
filters.param.watch(log_filter_change, ["year", "manufacturers"])
The rest covers runtime behavior once the app is served. It captures the gotchas and when-to-use judgment only — for full signatures and options, follow the linked Panel guides rather than relying on this page.
The Session Model¶
Each browser tab that connects to a served app gets its own session backed by a separate Bokeh Document; the app code runs once per session.
- Gotcha — module-level mutable globals are shared across all sessions. A list, dict, or DataFrame created at module scope is one object for every user; mutating it leaks state between sessions. Keep per-user state on instances (your
Viewer/Parameterizedobjects), and usepn.state.cacheonly for intentionally shared, read-mostly data. pn.stateis the per-session runtime handle (served,curdoc,session_args,cache, scheduling, lifecycle). Don't stash per-user mutable state on it casually.
panel serve app.py (CLI) is the standard way to deploy; reach for pn.serve(...) only when you need to launch from inside Python. See Panel's server guide.
Server-Side State and Scheduling¶
Periodic callbacks, deferred execution (pn.state.execute), and session lifecycle hooks (onload, on_session_created/on_session_destroyed, schedule_task) all live on pn.state — see Panel's Callbacks guide. What agents get wrong:
pn.state.add_periodic_callback(fn, period=...)—periodis in milliseconds, and the returned handle must be.stop()-ed. Tie it to the session so it stops on disconnect.- Use
pn.state.execute(fn)to defer heavy work off a callback so the UI stays responsive instead of blocking until the handler returns.
Streaming with Generators¶
For incremental feedback during long work, bind pn.bind to a generator (sync or async) — each yield replaces the rendered output, so you avoid manual callbacks and state flags. Full guide: Binding generators.
The one rule agents miss: a bound generator may yield repeatedly but must never return a value — use a bare return only to stop early.
def runner(run):
if not run:
yield "Not run yet"; return # bare return — no value
for i in range(101):
yield pn.Column(f"{i}%", pn.indicators.Progress(value=i))
yield "Done ✅"
pn.Row(button, pn.bind(runner, button))
Caching¶
Panel has three tiers (full API in the Caching guide) — the choice is the part worth knowing:
pn.state.cache— a plain process-global dict shared across sessions. For a one-time dataset load shared by everyone.pn.state.as_cached(key, fn, ttl=..., **kwargs)— same intent, no manualif-check; reruns only when the hashedkwargschange.@pn.cache— memoize a function on its arguments; reach for it when results depend on inputs (supportsttl,max_items,policy,to_disk,per_session).
Warm the cache before the first visitor with panel serve --warm (and --setup script.py to populate from a separate script).
Automatic Threading¶
pn.extension(nthreads=N) dispatches every event onto a thread pool automatically — no manual thread management (details in the Concurrency guide). The non-obvious bit: nthreads=0 auto-sizes to min(32, os.cpu_count() + 4). Use threading for CPU-bound callbacks; prefer async/await for I/O.
Profiling¶
Profile a callback with @pn.io.profile("name", engine=...) (engines: pyinstrument, snakeviz, memray); results appear in the /admin dashboard (panel serve --admin). The function is pn.io.profile, not pn.io.profiler — a common hallucination.
Batching, Loading, and Memory¶
- Batch updates: wrap multiple component assignments in
pn.io.hold()so they trigger a single redraw instead of one per assignment.
- Defer heavy components:
pn.extension(defer_load=True, loading_indicator=True)renders the page first and loads slow panes afterward with a spinner. - Loading spinner: wrap a slow update in the component's
loadingflag —with self._main.param.update(loading=True): ...sets it on enter and reverts on exit. Caveat: a synchronous callback won't flush the spinner until it returns; make the slow workasyncif you need it visible during the load. - Memory: cap streaming history, call
pn.state.clear_caches()when appropriate, and schedule periodic restarts for long-running deployments.