provideFFlow(withFlowState()) turns on the graph state built into Foblex Flow. The application loads plain data once, renders the nodes, groups and connections signals with @for, and reads the data back whenever it needs to persist — there is not a single gesture handler in the component below.
Your records are your own shape: extend the framework interface with any fields — no data wrapper.
Drag an item from the palette to add a node, drop a node into the group to nest it, drag between connectors to wire them, or select and press Delete: these supported gestures land in the state as undoable steps without component event handlers.
[example.html] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/libs/f-examples/advanced/flow-state/example.html [example.ts] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/libs/f-examples/advanced/flow-state/example.ts [example.scss] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/libs/f-examples/advanced/flow-state/example.scss
The controller forwards the supported finished gestures into the store. All events belonging to one drag session are batched into one undoable history step:
sourceId/targetId.undo() returns the whole group.Delete key from the accessibility layer) removes selected records. Because this gesture runs against a rendered flow, the connector registry is available and attached connections cascade with their nodes/groups.withFlowState({ dropToGroup: true }). With it on, dropped nodes and groups get the group's id as parentId; leave it off and the drop is a no-op and external items land at the top level.fData spread onto the node as its own fields; dropped over a group it's nested there (because this example opted into dropToGroup).state.transform() and is undoable by default. Bind the canvas [position]/[scale] to state.transform() (as this example does) so undo/redo move the view back. Set withFlowState({ canvasTransformInHistory: false }) to keep pan/zoom out of history — it's still tracked and saved in snapshot() either way. position starts undefined (before any transform), which leaves [position] free for the initial resetScaleAndCenter.false as the emitCanvasChange argument to resetScaleAndCenter, fitToScreen or centerGroupOrNode when a library-driven move must stay out of state history. This example initializes the viewport with resetScaleAndCenter(false, false).undo returns to the beginning of history, the flow's reset() re-runs the full-render lifecycle, so the app's fFullRendered handler (resetScaleAndCenter here) runs again.fCanvasChange events. The state plugin waits 350ms by default, then records the settled live canvas transform as one change and one undo step. Setting canvasTransformDebounce: 0 disables that batching: every emitted canvas change is applied immediately, increments state.changes(), and normally creates its own undo step. This also means a persistence effect that depends on state.changes() runs for every emitted zoom change. Leave the canvas-level [debounceTime] unset when state owns viewport history; stacking both debounce layers delays persistence and can make the State plugin receive stale intermediate transforms. A drag pan still folds into one step at drag end.Managed state v1 does not capture rotation, connection waypoint editing, or user resize. Those interactions continue to update the rendered directives and emit their normal public outputs; applications that enable them must update their own records. Library-driven size measurement and group auto-fit may amend the current stored geometry, but they do not create a separate user history step.
state.undo() / state.redo() walk the history; canUndo / canRedo are signals, ready for [disabled] bindings.withFlowState({ historyLimit: 100 }) caps the stack (default 50); the oldest steps fall off.load() replaces the whole graph and resets the history — ideal for opening a document; clearHistory() keeps the data and drops only the steps.selectionInHistory) a selection plus a move — they are folded into a single undoable action, so one undo reverts the whole thing.state.changes() ticks when a standalone mutation or an outer batch settles, and on undo, redo or load. A multi-event gesture such as selection plus move increments it once when the gesture finishes. Depend on it from a single effect to react to anything that touches the graph, without wiring one listener per gesture:
The store changes its collection signals immediately while a gesture is in progress, but delays the changes() increment until the outer batch closes. Standalone mutations notify immediately. The per-collection signals (nodes(), groups(), connections(), selection()) remain available when you only care about one slice.
load({ nodes, groups, connections }) — plain arrays in. The store only reads the framework keys (id, position, optional size, rotate, parentId) and carries the rest of your record through untouched — put your fields right on it, no data wrapper.fGroup — a first-class kind because the library treats them separately (a group id in a selection, its own drop target). Nesting is just parentId.snapshot() — plain arrays out (nodes, groups, connections) with copied geometry; persist the result as-is.injectFlowState<MyNode, MyConnection, MyGroup>() types all three end to end; each argument defaults to the framework shape, so injectFlowState<MyNode>() is enough when only nodes carry extra fields.| Option | Default | Meaning |
|---|---|---|
historyLimit |
50 |
Maximum number of undo steps. |
selectionInHistory |
true |
Include selection in undo/redo; a drag's leading selection is batched with the move. |
canvasTransformInHistory |
true |
Include user pan/zoom in undo/redo. The transform is still tracked when disabled. |
canvasTransformDebounce |
350 |
Settle and batch viewport events; 0 records every emitted change immediately. |
dropToGroup |
false |
Reparent dropped records and nest external items into groups. |
connectionFactory |
generated id/endpoints | Create or veto a gesture-created connection. |
nodeFactory |
payload plus normalized drop rect | Create or veto a node dropped from an external palette. |
stateClass |
FFlowState |
Install an application subclass that overrides store behavior. |
| API | Purpose |
|---|---|
nodes, groups, connections |
Readonly collection signals rendered by the template. |
selection, transform |
Current selection and canvas transform signals. |
canUndo, canRedo, changes |
History availability and settled-change notification signals. |
load(data), snapshot() |
Replace/reset the store and export persistable data. |
getNode, getGroup, getConnection |
Read one record by id. |
add*, update*, remove*, moveNodes |
Programmatic immutable mutations. |
undo, redo, clearHistory |
History operations. |
batch(work) |
Collapse synchronous mutations into one undo step and one changes() increment. |
beginBatch(), endBatch() |
Keep one transaction open across asynchronous work such as content-driven reflow. |
state.selection() always reflects the current selection (nodeIds, groupIds, connectionIds) as a signal — handy for a properties panel or a context menu. Whether selecting is undoable is a config switch, because editors disagree:
undo/redo restore the previous highlight (Figma, Photoshop); a drag's leading selection folds into the same step as the move.false to keep selection out of the history, so undo walks only graph edits (xyflow, tldraw).Everything the gestures do is also available as methods — and every call is one undoable step:
Expanding a node can produce two state changes that belong to one user action:
isExpanded immediately.ResizeObserver runs withReflowOnResize, and the plugin emits the resulting node positions through fMoveNodes on a later rendering turn.batch(() => ...) only covers synchronous work, so it closes before the reflow positions arrive. Use beginBatch() and endBatch() to keep the same transaction open across the render boundary. The state controller applies the reflow fMoveNodes event automatically while that transaction is still open:
The first mutation records the shape from before the click. Any positions emitted by reflow amend that same history entry, and the outer endBatch() produces one changes() tick. One undo() therefore restores both the collapsed/expanded value and every node moved by reflow.
Two animation frames are appropriate when the size change is rendered immediately: the first lets Angular update and measure the DOM, and the second lets the ResizeObserver-driven move settle. If the node size depends on a CSS transition, lazy content, or another asynchronous layout step, close the batch from that operation's real completion signal instead. Always pair beginBatch() with endBatch() in finally; leaving a batch open would merge later, unrelated edits into the same history item.
If the expand/collapse control is nested inside an fDragHandle, add fDragBlocker to that control. Otherwise the pointer down can select the node before the click handler runs. With selectionInHistory: true, that selection is correctly recorded as its own undo item, so the user action appears to require an extra undo even though expand and reflow are already batched together.
The Call Center Flow uses this pattern when its embedded node forms expand and collapse. The Reflow on Resize guide explains how the layout move itself is calculated.
Connections store connector ids (sourceId/targetId), not owner node ids. While the flow is rendered, the controller resolves each connector to its node/group and removeNodes/removeGroups automatically remove attached connections. Before connector directives have registered (for example, programmatic editing immediately after load() or during SSR), that ownership map does not exist.
When removing records before render, compute the attached connection ids from your data and delete both inside one batch:
For light-touch control, pass factories to the config — return null to reject:
If a default does not fit, subclass the store — any CRUD method, any apply* gesture handler, any protected building block — and install your class. The auto-wiring dispatches through it:
nodes[]/edges[]-style API.