Controlled mode and undo/redo
Uncontrolled vs controlled mode, the built-in undo/redo history, the imperative handle, and the hooks to observe or intercept changes.
DashfooLayout runs in one of two modes, decided by which prop you pass.
Give it defaultModel and the layout owns its document: an XState actor
holds the model plus a full undo/redo history, and you can ignore the
internals. Give it model and onModelChange instead, and the prop
becomes the source of truth. Every edit routes out to you, and nothing
changes on screen until you feed a new model back in.
This guide covers both modes, the imperative handle that drives the layout from outside, and a worked controlled-mode example: external history with undo/redo and a live JSON inspector.
The two modes
The mode is mutually exclusive at the prop level.
| Mode | Prop you pass | Who owns the model | History |
|---|---|---|---|
| Uncontrolled | defaultModel | the actor inside | built in |
| Controlled | model + onModelChange | your component | yours to keep |
Both go through useDashfooStore, which DashfooLayout calls for you. The
hook is also a public export of @dashfoo/react for headless use. Its full
options type:
type UseDashfooStoreOptions = {
defaultModel?: Dashfoo;
model?: Dashfoo;
onAction?: (action: Action) => Action | null;
onActiveTabsetChange?: (tabsetId: string | undefined) => void;
onMaximizedTabsetChange?: (tabsetId: string | undefined) => void;
onModelChange?: (model: Dashfoo, action?: Action) => void;
};The last four are the same callbacks DashfooLayout accepts as props; it
forwards them straight through. They work in both modes and are covered in
Observing and intercepting changes.
Pass one of model or defaultModel. Passing neither throws:
// useDashfooStore requires either a `model` or a `defaultModel`.Uncontrolled: the actor owns history
This is the short path. Hand over an initial Dashfoo model and walk away.
import { DashfooLayout } from "@dashfoo/react";
import { playgroundModel } from "./models";
import { renderPanel } from "./panels";
const Workspace = () => <DashfooLayout defaultModel={playgroundModel()} factory={renderPanel} />;Behind the scenes the actor keeps a History record: past, present,
and future. Every dispatched Action runs through the pure reducer and
pushes a new entry. The store derives canUndo / canRedo / undo /
redo from that history, and the imperative handle
surfaces them, so chrome controls can wire up without touching internals.
onModelChange is optional here. Add it and you get a read-only feed of
every committed model (and the action that produced it) without giving
up the actor's own history. The actor stays the source of truth.
One action, one undo step
There is no coalescing. Every committed action pushes exactly one undo step and clears the redo branch:
type History = {
future: Array<Dashfoo>;
past: Array<Dashfoo>;
present: Dashfoo;
};
const dispatch = (history: History, action: Action): History => {
const present = reducer(history.present, action);
return { future: [], past: [...history.past, history.present], present };
};A splitter drag still undoes as a single step, but not because the history
folds anything: the resize layer (react-resizable-panels v4) commits one
adjustSplit when the drag is released, not a burst of actions per frame.
The reasoning is recorded in
packages/core/src/state/history.ts.
canUndo and canRedo on the store are functions, not booleans. They read
the live actor snapshot, so a caller checking them right after undo()
(inside onModelChange, for instance) sees the fresh value rather than a
one-render-stale one.
The imperative handle
DashfooLayout exposes an imperative API via ref, so a toolbar, command
palette, or keyboard shortcut can drive the layout without rebuilding the
store's plumbing:
type DashfooHandle = {
addTab: (
tab: TabNode,
target: { index?: number; location?: DockLocation; targetId: string },
) => void;
canRedo: () => boolean;
canUndo: () => boolean;
closeTab: (tabId: string) => void;
dispatch: (action: Action) => void;
getModel: () => Dashfoo;
maximizeTabset: (tabsetId: string | null) => void;
redo: () => void;
renameTab: (tabId: string, name: string) => void;
resetLayout: () => void;
selectTab: (tabsetId: string, index: number) => void;
undo: () => void;
};| Method | What it does |
|---|---|
addTab | Inserts a tab at targetId (an existing tabset id). location defaults to "center" (into the tabset, selected); "split-*" creates a new tabset beside it. index positions within the strip. |
canUndo/canRedo | Read the live history flags. |
closeTab | Dispatches deleteTab for the given tab id. |
dispatch | Escape hatch: send any raw Action. |
getModel | Returns the current model. |
maximizeTabset | Maximizes the tabset, or restores with null. |
undo/redo | Step through the built-in history. |
renameTab | Dispatches renameTab with the new name. |
resetLayout | Restores defaultModel, clearing undo history and any persisted copy. |
selectTab | Selects the tab at index in the given tabset. |
The action-dispatching methods work in both modes. In controlled mode the
resulting model arrives through onModelChange like any other edit, computed
against the model prop you are currently rendering: an edit you choose not to
apply is simply not applied, and the next one starts from the same place.
undo, redo, and resetLayout are uncontrolled-only; in controlled mode they
warn once and do nothing, because the host owns history and the default model.
An action that changes nothing (clicking the already-selected tab, an unknown
id) is not an edit at all: reducer returns the same model by reference, so
onModelChange does not fire and, uncontrolled, no undo step is recorded and the
redo stack survives.
A worked runtime example that opens a tab from a button and adds undo/redo and reset:
import { tab } from "@dashfoo/core";
import type { DashfooHandle } from "@dashfoo/react";
import { DashfooLayout } from "@dashfoo/react";
import type { ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
import { playgroundModel } from "./models";
import { renderPanel } from "./panels";
const defaultModel = playgroundModel();
const Workspace = (): ReactNode => {
const layout = useRef<DashfooHandle>(null);
const [history, setHistory] = useState({ canRedo: false, canUndo: false });
// canUndo/canRedo read the live actor snapshot, so sample them when the
// model changes (an event), not during render.
const handleModelChange = useCallback((): void => {
setHistory({
canRedo: layout.current?.canRedo() ?? false,
canUndo: layout.current?.canUndo() ?? false,
});
}, []);
const handleAddChart = (): void => {
layout.current?.addTab(tab("chart", "New chart", { id: crypto.randomUUID() }), {
location: "split-right",
targetId: "main",
});
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div aria-label="Layout controls" role="toolbar">
<button onClick={handleAddChart} type="button">
Add chart
</button>
<button disabled={!history.canUndo} onClick={() => layout.current?.undo()} type="button">
Undo
</button>
<button disabled={!history.canRedo} onClick={() => layout.current?.redo()} type="button">
Redo
</button>
<button onClick={() => layout.current?.resetLayout()} type="button">
Reset
</button>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<DashfooLayout
defaultModel={defaultModel}
factory={renderPanel}
onModelChange={handleModelChange}
ref={layout}
/>
</div>
</div>
);
};Two details to get right. targetId must be the id of a tabset that exists
in the model (the dispatch is a silent no-op otherwise), so give your drop
target an explicit id (here "main") when building the model. And a tab's
id defaults to its component string, so adding a second "chart" tab
without an explicit id collides with the first; generate one per call.
The demo's controlled page
is this pattern end to end: handle-driven undo/redo buttons, ⌘Z / ⇧⌘Z
shortcuts, and a live model inspector fed from onModelChange.
Controlled: the prop is the source of truth
In controlled mode you hold the model in your own state and pass it down. The layout renders the model you give it and never mutates on its own.
const Workspace = () => {
const [model, setModel] = useState(() => playgroundModel());
return <DashfooLayout factory={renderPanel} model={model} onModelChange={setModel} />;
};When model is set, a dispatch never touches the actor. The store runs
onAction first (which may veto or replace the action), computes the next
model with the pure reducer, and hands the result to onModelChange. A
dispatch that produces no change emits nothing.
Two consequences follow from this.
First, the screen does not update until your new model arrives. If
onModelChange does nothing, the layout sits still. You own the model, so you
decide when it changes. A useEffect
syncs each new model prop back into the actor, so the inspector and any
internal selectors still observe the current document.
Second, undo and redo are no-ops in controlled mode. Both warn once and
return without touching anything or firing onModelChange, and canUndo /
canRedo always report false. The internal actor still records what you
dispatch, but the controlled sync resets it on every new model, so it is
never a stack you can walk. If you want undo/redo in controlled mode, you
keep the history yourself. That's the
worked example below.
Models are normalized at the boundary
Whatever model enters the store gets run through normalize first,
whether it came from model or defaultModel:
const actorRef = useActorRef(dashfooMachine, { input: { model: normalize(initialModel) } });The controlled sync does the same on every update:
actorRef.send({ model: normalize(controlledModel), type: "SET_MODEL" });normalize enforces the same structural invariants the reducer guarantees: no
empty tabsets, no redundant single-child rows, a maximizedTabsetId that is live
and in the main layout. Every
entry point holds a canonical model, so a host-supplied document satisfies
the same rules as one the reducer produced. You don't have to hand-build a
perfectly-formed tree. Pass something reasonable and the boundary cleans it.
Worked example: external history + undo/redo + inspector
This is the controlled-mode recipe for undo/redo: keep a past /
present / future record in component state, drive the layout from
present, and rebuild the stack on every change.
The history shape, kept locally:
type History = { future: Array<Dashfoo>; past: Array<Dashfoo>; present: Dashfoo };
const [history, setHistory] = useState<History>(() => ({
future: [],
past: [],
present: initial,
}));Each change pushes the old present onto past and clears future (a new
edit invalidates the redo branch):
const handleChange = useCallback((model: Dashfoo): void => {
setHistory((current) => ({
future: [],
past: [...current.past, current.present],
present: model,
}));
}, []);Undo and redo move snapshots between the three stacks:
const handleUndo = useCallback((): void => {
setHistory((current) => {
const previous = current.past.at(-1);
if (!previous) {
return current;
}
return {
future: [current.present, ...current.future],
past: current.past.slice(0, -1),
present: previous,
};
});
}, []);
const handleRedo = useCallback((): void => {
setHistory((current) => {
const next = current.future[0];
if (!next) {
return current;
}
return {
future: current.future.slice(1),
past: [...current.past, current.present],
present: next,
};
});
}, []);@dashfoo/core's History uses the same algorithm internally. Replicating it
in user space is the point of controlled mode: you decide
the policy. Cap the stack depth, persist it to a server, branch it, merge
two histories. The layout stays a pure function of present.
Keyboard shortcuts
Bind undo/redo to the platform shortcuts, and skip them while the user is typing in a field (renaming a tab, for instance):
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
const target = event.target;
if (
target instanceof HTMLElement &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA")
) {
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
handleRedo();
} else {
handleUndo();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleRedo, handleUndo]);Layout, controls, and the inspector
present drives the layout. The undo/redo buttons disable on empty
stacks. The JSON inspector renders present straight, so it updates with
every committed change:
<DashfooLayout
factory={renderPanel}
model={history.present}
onModelChange={handleChange}
/>
<pre>{JSON.stringify(history.present, null, 2)}</pre>Because present is the same object the layout renders from, the inspector
is exact: there is no separate projection to keep in sync, and the model
you read is the model on screen.
Observing and intercepting changes
Beyond onModelChange, DashfooLayout exposes hooks that work in either mode:
| Prop | When it fires / what it does |
|---|---|
onAction | Before each action commits. Return the action, a replacement, or null to veto it. |
onActiveTabsetChange | When the active tabset id changes. |
onMaximizedTabsetChange | When a tabset is maximized or restored. |
renderTabLabel | Override a tab's rendered label (the accessible name stays tab.name). |
renderTabsetToolbar | Inject custom controls into a tabset's toolbar. |
The first three are also useDashfooStore options; the two render props
belong to the layout component only.
onAction is the interception point, e.g. to confirm before a deleteTab
or remap a drop:
<DashfooLayout
defaultModel={model}
onAction={(action) => (action.type === "deleteTab" && !confirm("Close tab?") ? null : action)}
/>Choosing a mode
| Question | Mode |
|---|---|
| Do you want a working layout with built-in undo/redo? | Uncontrolled |
| Do you need to persist or sync the model elsewhere? | either |
| Do you need custom history (depth caps, branching)? | Controlled |
| Do you need toolbar- or shortcut-driven edits? | either (use the handle) |
| Do you want the smallest amount of wiring? | Uncontrolled |
Start uncontrolled. Pass defaultModel, add onModelChange if you need
to observe or persist, and drive runtime edits through the imperative
handle. Move to controlled only when you need the model to be yours:
external state, a custom undo policy, or a document that lives outside
React entirely.
See also
apps/demo-vite/src/pages/controlled.tsxfor the imperative handle and history demo, end to end.packages/react/src/hooks/store.tsfor the store wiring, including the controlled-mode guards.packages/react/src/components/dashfoo-layout.tsxforDashfooHandleand the full prop surface.packages/core/src/state/history.tsfor the built-in history implementation.packages/core/src/model/invariants.tsfor the boundary invariants behindnormalize.
Drag and dock
How dragging a tab or a whole tabset resolves to a stack or a directional split. The landing zones, the pipeline, and the gates. Pointer-only.
Build your own layout
Compose the Layout and Tabset primitives into a custom layout from the same parts DashfooLayout is built from, with your own chrome, labels, and toolbars.