dashfoo

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.

ModeProp you passWho owns the modelHistory
UncontrolleddefaultModelthe actor insidebuilt in
Controlledmodel + onModelChangeyour componentyours 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;
};
MethodWhat it does
addTabInserts 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/canRedoRead the live history flags.
closeTabDispatches deleteTab for the given tab id.
dispatchEscape hatch: send any raw Action.
getModelReturns the current model.
maximizeTabsetMaximizes the tabset, or restores with null.
undo/redoStep through the built-in history.
renameTabDispatches renameTab with the new name.
resetLayoutRestores defaultModel, clearing undo history and any persisted copy.
selectTabSelects 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:

PropWhen it fires / what it does
onActionBefore each action commits. Return the action, a replacement, or null to veto it.
onActiveTabsetChangeWhen the active tabset id changes.
onMaximizedTabsetChangeWhen a tabset is maximized or restored.
renderTabLabelOverride a tab's rendered label (the accessible name stays tab.name).
renderTabsetToolbarInject 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

QuestionMode
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