OpenSpace push-based-widget-module-pattern
How to implement sidebar/widget UI components that receive pushed data updates from a caller, using a singleton module pattern with exported factory and update functions plus module-level DOM refs for efficient partial re-renders.
git clone https://github.com/HKUDS/OpenSpace
T=$(mktemp -d) && git clone --depth=1 https://github.com/HKUDS/OpenSpace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/showcase/skills/push-based-widget-module-pattern" ~/.claude/skills/hkuds-openspace-push-based-widget-module-pattern && rm -rf "$T"
showcase/skills/push-based-widget-module-pattern/SKILL.mdPush-Based Widget Module Pattern
Use this pattern when a UI sidebar or widget component needs to be updated by its caller (push-based) rather than fetching data itself. Instead of a class or framework component, implement the widget as a plain module with singleton state, a factory function to create the DOM, and an update function to efficiently patch it.
When to Use
- A sidebar, panel, or widget is rendered once but updated frequently with new data.
- The data source lives outside the component (e.g., a parent controller, event bus, or store).
- You want lightweight partial re-renders without rebuilding the entire DOM tree.
- You prefer a simple module interface over a class-based or framework-based component.
Core Structure
widgets/ MyWidget.ts # The module (singleton state + exports) MyWidget.types.ts # (Optional) Exported data interface
Step-by-Step Instructions
1. Define the Data Interface
Export a typed interface so callers know exactly what data to push.
// MyWidget.types.ts (or inline at top of MyWidget.ts) export interface MyWidgetData { title: string; items: string[]; status: "idle" | "active" | "error"; // ... all fields the widget needs to render }
2. Declare Module-Level Singleton Refs
At module scope, keep references to the container and any frequently updated child nodes. This avoids querying the DOM on every update.
// MyWidget.ts import type { MyWidgetData } from "./MyWidget.types"; // Singleton DOM refs let containerEl: HTMLElement | null = null; let titleEl: HTMLElement | null = null; let itemListEl: HTMLElement | null = null; let statusEl: HTMLElement | null = null; // Current state snapshot (optional — useful for diffing) let currentData: MyWidgetData | null = null;
3. Export a Factory Function (createMyWidget
)
createMyWidgetThe factory builds the full DOM tree once, stores refs to mutable nodes, and returns the root element to be mounted by the caller.
export function createMyWidget(): HTMLElement { // Build the container containerEl = document.createElement("aside"); containerEl.className = "my-widget"; // Build static structure; keep refs to dynamic parts titleEl = document.createElement("h2"); titleEl.className = "my-widget__title"; itemListEl = document.createElement("ul"); itemListEl.className = "my-widget__list"; statusEl = document.createElement("span"); statusEl.className = "my-widget__status"; // Compose the tree const header = document.createElement("header"); header.appendChild(titleEl); header.appendChild(statusEl); containerEl.appendChild(header); containerEl.appendChild(itemListEl); return containerEl; }
Rule:
should be callable only once per page. If called again, consider returning the existingcreateMyWidget(idempotent factory).containerEl
4. Export an Update Function (updateMyWidget
)
updateMyWidgetThe update function receives the new data object and performs surgical DOM mutations on the stored refs. It does NOT rebuild the whole tree.
export function updateMyWidget(data: MyWidgetData): void { if (!containerEl) { console.warn("updateMyWidget called before createMyWidget"); return; } currentData = data; // Update only what changed if (titleEl) titleEl.textContent = data.title; if (statusEl) { statusEl.textContent = data.status; statusEl.dataset.status = data.status; // for CSS hooks } // Rebuild only dynamic list items if (itemListEl) { itemListEl.innerHTML = ""; // clear previous items data.items.forEach((item) => { const li = document.createElement("li"); li.textContent = item; itemListEl!.appendChild(li); }); } }
Optimization tip: For large lists, compare
tocurrentDatabefore clearing and rebuilding, or use a keyed reconciliation approach.data
5. Caller Usage Pattern
The caller mounts the widget once and pushes updates whenever its data changes.
import { createMyWidget, updateMyWidget } from "./widgets/MyWidget"; import type { MyWidgetData } from "./widgets/MyWidget.types"; // Mount once const sidebar = document.getElementById("sidebar")!; sidebar.appendChild(createMyWidget()); // Push updates (e.g., from a store subscription, WebSocket, timer) function onDataChange(newData: MyWidgetData) { updateMyWidget(newData); } // Example: push an initial state immediately onDataChange({ title: "Today's Focus", items: ["Task A", "Task B"], status: "active", });
Full Module Template
// widgets/MyWidget.ts export interface MyWidgetData { // Define all fields here } // --- Singleton state --- let containerEl: HTMLElement | null = null; // Add more refs as needed per section of the widget // --- Factory --- export function createMyWidget(): HTMLElement { if (containerEl) return containerEl; // idempotent containerEl = document.createElement("aside"); // ... build DOM, store refs ... return containerEl; } // --- Update --- export function updateMyWidget(data: MyWidgetData): void { if (!containerEl) return; // ... patch DOM refs with new data ... }
Guidelines & Best Practices
| Concern | Recommendation |
|---|---|
| Idempotent factory | Return existing if called twice to avoid duplicate mounts. |
| Null guards | Always check refs before mutating — the update fn may be called before mount in some flows. |
| Granular refs | Store refs for every section that updates independently. Avoid in the update path. |
| Interface exports | Always export the data interface so callers can type their push calls. |
| No internal fetching | The module must never fetch or subscribe to external data — that is the caller's responsibility. |
| CSS hooks | Use attributes or CSS classes on refs to let stylesheets react to state changes. |
| Cleanup | Optionally export a that nulls all refs for SPA teardown scenarios. |
Anti-Patterns to Avoid
- ❌ Class with constructor — adds instantiation overhead and prevents the clean factory/update split.
- ❌ Querying the DOM in the update function (
) — slow and fragile.document.querySelector(...) - ❌ Rebuilding the entire tree on every update — defeats the purpose of the module-level refs.
- ❌ Mixing data-fetching into the widget module — breaks the push-based contract.