Skip to main content

Multi-Scope Routing

A single <AmnesiaProvider> can host many independent history scopes. A canvas, a property panel, and a layer tree each get their own past / future / capacity / coalesce window. Focus claims route Ctrl+Z to the scope the user is currently working in.

import {
AmnesiaProvider,
AmnesiaShortcuts,
useAmnesiaFocusClaim,
useAmnesiaScopes,
useUndoableState,
} from "react-amnesia";

function CanvasArea() {
const claim = useAmnesiaFocusClaim("canvas");
const [strokes, setStrokes] = useUndoableState<string[]>([], { scopeId: "canvas" });
return (
<section tabIndex={-1} {...claim}>
<p>{strokes.length} strokes</p>
<button onClick={() => setStrokes((s) => [...s, "stroke"])}>Add stroke</button>
</section>
);
}

function PropertyPanel() {
const claim = useAmnesiaFocusClaim("props");
const [title, setTitle] = useUndoableState("Untitled", {
scopeId: "props",
coalesceKey: "edit:title",
});
return (
<aside tabIndex={-1} {...claim}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</aside>
);
}

function Breadcrumb() {
const { activeScopeId } = useAmnesiaScopes();
return <span>Editing: {activeScopeId}</span>;
}

export function App() {
return (
<AmnesiaProvider scopes={{ canvas: { capacity: 1000 }, props: { capacity: 100 } }}>
<AmnesiaShortcuts />
<Breadcrumb />
<CanvasArea />
<PropertyPanel />
</AmnesiaProvider>
);
}

How it works

  • Each named scope is created lazily on first access. The implicit "default" scope exists too.
  • useAmnesiaFocusClaim(scopeId) returns { onFocusCapture, onPointerDownCapture } handlers. Spread them onto a focusable container — clicking or focusing the element claims that scope as active.
  • <AmnesiaShortcuts /> (without a scopeId prop) routes the chord to the active scope. Pin to one scope with <AmnesiaShortcuts scopeId="canvas" />.
  • Only one focused-child claim is held at a time. The most recently claimed scope wins. When the claiming component unmounts, the active scope falls back to default.

useUndoableState is always pinned

useUndoableState accepts scopeId in its options and pins the component to that scope. It does not float to the active claim — React state lives in stable component instances, so the history surface it belongs to should be a stable property, not focus-driven.

useUndoableState(initial, { scopeId: "canvas" });

If you want a component that follows the active scope (say, an <UndoToolbar /> that always reflects the focused surface), use useAmnesia() with no argument:

function UndoToolbar() {
const { undo, redo, canUndo, canRedo } = useAmnesia(); // tracks active
return (
<div>
<button disabled={!canUndo} onClick={() => undo()}>
Undo
</button>
<button disabled={!canRedo} onClick={() => redo()}>
Redo
</button>
</div>
);
}

Per-scope option overrides

<AmnesiaProvider
capacity={100}
coalesceWindowMs={400}
scopes={{
canvas: { capacity: 1000, coalesceWindowMs: 50 },
props: { capacity: 50 },
}}
>

Provider-level options are the defaults; per-scope entries override. Settings are read at scope-creation time and frozen after that.

Provider-wide clear

useAmnesiaScopes() returns clear(scopeId?):

  • clear() — every registered scope
  • clear("canvas") — just one scope

Useful for document switches: clear everything when the user opens a different document.

See also