Tutorial 3: Multiple Panels
Most real instruments use two panels: a sidebar for navigation and a second panel for details. This tutorial shows how to wire them together.
The pattern
Section titled “The pattern”Since each panel is a separate React tree (they run in independent mount points), they can’t share React state directly. Instead, use this flow:
- Write the selected item ID to
storage.setProperty() - Emit a custom event with
api.emit() - The other panel subscribes to
instrument.eventwithuseHostEvent() - On receiving the event, it reads the selected ID and loads detail data
Manifest
Section titled “Manifest”Enable both sidebar and second panels in package.json:
{ "tango": { "instrument": { "panels": { "sidebar": true, "first": false, "second": true, "right": false }, "permissions": ["storage.properties"] } }}Shared types
Section titled “Shared types”Define shared types at the top of your src/index.tsx:
type Item = { id: string; name: string; description: string;};
const ITEMS: Item[] = [ { id: "1", name: "Alpha", description: "First item with details" }, { id: "2", name: "Beta", description: "Second item with details" }, { id: "3", name: "Gamma", description: "Third item with details" },];
const SELECTED_KEY = "__ui_selectedItemId";Sidebar panel — the list
Section titled “Sidebar panel — the list”The sidebar shows items and writes the selection to storage + emits an event:
import { useCallback, useEffect, useRef, useState } from "react";import { defineReactInstrument, useInstrumentApi, useHostEvent, UIRoot, UIPanelHeader, UISection, UIGroupList, UIGroupItem, UIBadge,} from "tango-api";
function SidebarPanel() { const api = useInstrumentApi(); const [selectedId, setSelectedId] = useState<string | null>(null);
// Restore persisted selection on mount const initialized = useRef(false); useEffect(() => { if (initialized.current) return; initialized.current = true; api.storage.getProperty<string>(SELECTED_KEY).then((id) => { if (id) setSelectedId(id); }); }, [api]);
const selectItem = useCallback((id: string) => { setSelectedId(id); // Persist so the other panel can read it api.storage.setProperty(SELECTED_KEY, id); // Notify the other panel api.emit({ event: "item.selected", payload: { itemId: id } }); }, [api]);
// Listen for selection changes from the other panel (if it can select too) useHostEvent("instrument.event", useCallback((payload) => { if (payload.instrumentId !== api.instrumentId) return; if (payload.event === "item.selected") { const itemId = String((payload.payload as any)?.itemId ?? ""); if (itemId) setSelectedId(itemId); } }, [api]));
return ( <UIRoot> <UIPanelHeader title="Items" rightActions={<UIBadge label={`${ITEMS.length}`} tone="info" />} /> <UISection> <UIGroupList> {ITEMS.map((item) => ( <UIGroupItem key={item.id} title={item.name} subtitle={item.description} active={item.id === selectedId} onClick={() => selectItem(item.id)} /> ))} </UIGroupList> </UISection> </UIRoot> );}Second panel — the detail view
Section titled “Second panel — the detail view”The second panel reads the persisted selection on mount, then listens for changes:
function SecondPanel() { const api = useInstrumentApi(); const [item, setItem] = useState<Item | null>(null);
// Restore persisted selection on mount const initialized = useRef(false); useEffect(() => { if (initialized.current) return; initialized.current = true; api.storage.getProperty<string>(SELECTED_KEY).then((id) => { if (id) setItem(ITEMS.find((i) => i.id === id) ?? null); }); }, [api]);
// Listen for selection events from the sidebar useHostEvent("instrument.event", useCallback((payload) => { if (payload.instrumentId !== api.instrumentId) return; if (payload.event === "item.selected") { const itemId = String((payload.payload as any)?.itemId ?? ""); const found = ITEMS.find((i) => i.id === itemId); setItem(found ?? null); } }, [api]));
return ( <UIRoot> <UIPanelHeader title={item?.name ?? "Item Detail"} subtitle={item ? `ID: ${item.id}` : "Select an item from the sidebar"} /> <UISection> {item ? ( <UICard> <p>{item.description}</p> </UICard> ) : ( <UIEmptyState title="No item selected" description="Click an item in the sidebar to see its details here." /> )} </UISection> </UIRoot> );}Wire it up
Section titled “Wire it up”export default defineReactInstrument({ defaults: { visible: { sidebar: true, second: true }, }, panels: { sidebar: SidebarPanel, second: SecondPanel, },});How it works
Section titled “How it works”┌─────────────┐ ┌──────────────┐│ Sidebar │ │ Second Panel ││ │ │ ││ selectItem │ │ ││ │ │ │ ││ ├───── storage.setProperty ──┤ ││ │ │ │ ││ └───── api.emit ─────────── useHostEvent ──► ││ │ │ setItem() │└─────────────┘ └──────────────┘- User clicks an item in the sidebar
storage.setPropertypersists the selection (survives page reloads)api.emitsends a real-time event- The second panel’s
useHostEventhandler receives it and updates its state
Next steps
Section titled “Next steps”Your panels are communicating, but all data is hardcoded. In the next tutorial you’ll move logic to a backend and call it from the frontend.