Skip to content

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.

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:

  1. Write the selected item ID to storage.setProperty()
  2. Emit a custom event with api.emit()
  3. The other panel subscribes to instrument.event with useHostEvent()
  4. On receiving the event, it reads the selected ID and loads detail data

Enable both sidebar and second panels in package.json:

{
"tango": {
"instrument": {
"panels": {
"sidebar": true,
"first": false,
"second": true,
"right": false
},
"permissions": ["storage.properties"]
}
}
}

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";

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>
);
}

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>
);
}
export default defineReactInstrument({
defaults: {
visible: { sidebar: true, second: true },
},
panels: {
sidebar: SidebarPanel,
second: SecondPanel,
},
});
┌─────────────┐ ┌──────────────┐
│ Sidebar │ │ Second Panel │
│ │ │ │
│ selectItem │ │ │
│ │ │ │ │
│ ├───── storage.setProperty ──┤ │
│ │ │ │ │
│ └───── api.emit ─────────── useHostEvent ──► │
│ │ │ setItem() │
└─────────────┘ └──────────────┘
  1. User clicks an item in the sidebar
  2. storage.setProperty persists the selection (survives page reloads)
  3. api.emit sends a real-time event
  4. The second panel’s useHostEvent handler receives it and updates its state

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.