Documentation
Guides

Status-aware tabs

Tabs that show what their content is doing, such as unsaved changes, errors or live counts, read from the tab's typed data.

A tab button and its content are rendered in different places (the strip and the panel layer), so a tab cannot read its content's state through props. Share it another way. Two patterns: the tab's own data, changed with a command, and a store of your own keyed by tab id.

Content-aware tabs
Gallery

1. The status in the tab's data

A tab's data is typed by its component (see Typed data). Put the status there, next to the tab's name:

import { createModel, type LayoutJson, type TabOf } from "@fragiola/dockable-react";

type Status = "healthy" | "degraded" | "down";

type Types = {
    tabs: {
        monitor: { status: Status; incidents: number };
        document: { dirty: boolean };
    };
};

const model = createModel<Types>(json); // json: LayoutJson<Types>

The content writes it with tab.set-data. data is a patch: the keys it passes replace the tab's, the others stay, and the result is checked against the tab's component:

import { type TabNode, useDockable } from "@fragiola/dockable-react";

function Editor({ tab }: { tab: TabNode<"document", Types["tabs"]["document"]> }) {
    const { model } = useDockable<Types>();
    const setDirty = (dirty: boolean) => {
        // only when the flag changes, not on every keystroke
        if (tab.data.dirty !== dirty) {
            model.run("tab.set-data", { tabId: tab.id, data: { dirty } });
        }
    };
    // … call setDirty(true) on edit, setDirty(false) on save
}

The tab reads tab.data. It is a union over the components, so data narrows on tab.component, with no cast:

function StatusTab({ tab }: { tab: TabOf<Types> }) {
    const status = tab.component === "monitor" ? tab.data.status : undefined;
    const dirty = tab.component === "document" && tab.data.dirty;
    return (
        <Dockable.Tab node={tab} data-status={status} data-modified={dirty ? "" : undefined}>
            {tab.label}
            {tab.component === "monitor" && tab.data.incidents > 0 ? (
                <span className="badge">{tab.data.incidents}</span>
            ) : null}
        </Dockable.Tab>
    );
}

Forwarded data-* props follow the same convention as the package's own: present or absent. Style them like the built-in state: data-modified:italic, .tab[data-status="down"] { color: red }.

The strip re-renders with the model, so the TabList child function always sees the current data. Because the status is in the model, it is:

  • saved with the layout: model.get("layout-json") writes it, and a restored layout shows it;
  • in every window: a popped-out tab reads the same model;
  • a command: a middleware sees it, model.subscribe reports it, and an undo history built on the model records it (see Undo and redo).

Use it for changes a user would recognize as a step (saved, modified, failed), not for a value that ticks every second. For those, the next pattern.

2. A store keyed by tab id

Values that change often and do not belong to the layout (a live counter, a progress bar) live in a small external store, keyed by tab.id. The content writes; the tab reads.

import { useSyncExternalStore } from "react";

type Live = { unread?: number };
const values = new Map<string, Live>();
const listeners = new Set<() => void>();

export const liveStore = {
    set(id: string, value: Live) {
        values.set(id, { ...values.get(id), ...value });
        for (const listener of listeners) listener();
    },
    subscribe(listener: () => void) {
        listeners.add(listener);
        return () => listeners.delete(listener);
    },
};

export function useLive(id: string): Live | undefined {
    return useSyncExternalStore(liveStore.subscribe, () => values.get(id));
}
// in the tab strip
function LiveTab({ tab }: { tab: TabOf<Types> }) {
    const live = useLive(tab.id);
    return (
        <Dockable.Tab node={tab}>
            {tab.label}
            {live?.unread ? <span className="badge">{live.unread}</span> : null}
        </Dockable.Tab>
    );
}

// in the content
liveStore.set(tab.id, { unread: 3 });

The content stays mounted across moves (and popouts), and a tab's id never changes within its model, so the content keeps writing to the same key wherever its tab goes. Drop a closed tab's entry from model.subscribe when you need to (event.command === "tab.close").

Closing a dirty tab

A middleware on tab.close can refuse to close a tab with unsaved changes. It sees every close, from a button, a key or a drop zone, and model.can("tab.close", …) then answers false, so a close button can hide itself:

import { veto } from "@fragiola/dockable-react";

model.use((ctx, next) => {
    if (ctx.command === "tab.close") {
        const tab = ctx.get("node-by", { id: ctx.payload.tabId });
        if (tab?.type === "tab" && tab.component === "document" && tab.data.dirty) {
            return veto("Save the document before closing it.");
        }
    }
    return next();
});

ctx.get("node-by", …) returns a node of the registry, so tab.data narrows once type and component are checked. More on closing, and on asking the user first, in Tabs.

A live dashboard

The ops monitor example drives tab badges from streaming data (each service panel writes its alert level into its tab's data when the level changes) and keeps its incident region locked with a middleware.

Ops monitor
Gallery