Controlled and uncontrolled state
The canvas keeps its own copy of your graph and reports most edits back to you. Decide whether to apply those reports with useGraph, in your own store, or not at all.
KookieFlow takes your entities and edges as props and keeps its own copy of them. When
someone edits the graph, the canvas changes that copy and reports most edits to you as a list
of changes. You choose what happens to those reports: let the canvas keep the graph,
apply them with useGraph, or apply them in a store you already have.
How the canvas holds your graph
The canvas draws from its own internal store, not from your props on every frame.
- A new array replaces the copy. When
entitiesoredgesis a different array from the one the canvas last received, the canvas replaces its copy with it. Passing the same array again does nothing. - An edit changes the copy and is reported. A moved node is drawn in its new place right
away, and the canvas calls
onEntitiesChangeoronEdgesChangewith what changed. - Your next array wins. If you apply the changes and pass new arrays back, your state and the canvas agree. If you ignore them, the canvas keeps showing the edit until you next pass a new array, which replaces it.
The canvas reports a drag once, when the pointer comes up, and a resize the same way. Holding the graph in React state therefore doesn't re-render your component while someone drags.
Two kinds of edit are the exception to the second rule: text typed into a text entity, and styles such as a colour or a font size chosen on the selection toolbar. The canvas reports these and doesn't apply them itself, so they appear only when you pass them back.
| Prop | Type | Default | What it does |
|---|---|---|---|
entities | Entity[] | Required | The entities to draw. A new array replaces the canvas's copy. |
edges | Edge[] | Required | The edges to draw. A new array replaces the canvas's copy. |
onEntitiesChange | (changes: EntityChange[]) => void | None | Receives every reported entity edit. |
onEdgesChange | (changes: EdgeChange[]) => void | None | Receives every reported edge edit. |
Let the canvas own the graph
entities and edges are required, but every handler is optional. Pass arrays that never
change, leave the handlers out, and read the graph back through the ref when you need it:
import { useRef } from "react";import { KookieFlow, type Edge, type Entity, type KookieFlowInstance } from "@kushagradhawan/kookie-flow";const entities: Entity[] = [ { id: "idea", type: "note", position: { x: 0, y: 0 }, data: { label: "Idea" } }, { id: "plan", type: "note", position: { x: 320, y: 0 }, data: { label: "Plan" } },];const edges: Edge[] = [];export function Sketchpad() { const flowRef = useRef<KookieFlowInstance>(null); const save = () => { const graph = flowRef.current?.toObject(); if (graph) localStorage.setItem("sketchpad", JSON.stringify(graph)); }; return ( <div style={{ display: "grid", gridTemplateRows: "auto 1fr", height: 520 }}> <button onClick={save}>Save</button> <KookieFlow ref={flowRef} entities={entities} edges={edges} /> </div> );}People can move, resize and delete nodes, draw with the pen, and drop or paste pictures,
video and models. getEntities(), getEdges() and toObject() return the canvas's copy, and
Saving and exporting covers the saved shape.
Three things don't work without handlers:
- New wires. The canvas calls
onConnectand creates no edge itself, so a dropped wire disappears. - Typed text. A text entity loses what you typed when editing ends.
- Toolbar styles. If you render a
Toolbar, a colour or size chosen on it doesn't stick.
Keep both arrays at module scope, or in useState or useMemo. If a parent re-renders and
builds a fresh array, the canvas replaces its copy with that array, and the edits people made
are gone.
Hold the graph with useGraph
useGraph keeps the entities and edges in React state and applies every change the canvas
reports. Use it when your component needs to read the graph as it changes, to show a count,
check the graph, or save on every edit:
import { KookieFlow, useGraph, type Entity } from "@kushagradhawan/kookie-flow";export function Editor({ initialEntities }: { initialEntities: Entity[] }) { const graph = useGraph({ initialEntities, history: true }); return ( <div style={{ display: "grid", gridTemplateRows: "auto 1fr", height: 560 }}> <header> {graph.entities.length} nodes, {graph.edges.length} wires <button onClick={graph.undo} disabled={!graph.canUndo}>Undo</button> </header> <KookieFlow entities={graph.entities} edges={graph.edges} onEntitiesChange={graph.onEntitiesChange} onEdgesChange={graph.onEdgesChange} onConnect={graph.onConnect} /> </div> );}Options
| Option | Type | Default | What it does |
|---|---|---|---|
initialEntities | Entity[] | [] | The entities to start with. Read on the first render only. |
initialEdges | Edge[] | [] | The edges to start with. Read on the first render only. |
history | boolean | UseGraphHistoryOptions | false | Turns on undo and redo. true keeps 50 steps and binds Cmd or Ctrl with Z while the canvas has focus. Undo and redo covers the options. |
Return value
| Name | Type | What it does |
|---|---|---|
entities | Entity[] | The current entities. Pass to KookieFlow. |
edges | Edge[] | The current edges. Pass to KookieFlow. |
onEntitiesChange | (changes: EntityChange[]) => void | Applies entity changes. Pass to KookieFlow. |
onEdgesChange | (changes: EdgeChange[]) => void | Applies edge changes. Pass to KookieFlow. |
onConnect | (connection: Connection) => void | Adds an edge for a new connection. Pass to KookieFlow. |
setEntities | Dispatch<SetStateAction<Entity[]>> | Replaces the entities, like any React state setter. |
setEdges | Dispatch<SetStateAction<Edge[]>> | Replaces the edges. |
addEntity | (entity: Entity) => void | Appends an entity. |
removeEntity | (id: string) => void | Removes an entity and every edge that starts or ends at it. |
addEdge | (edge: Edge) => void | Appends an edge. |
removeEdge | (id: string) => void | Removes an edge. |
getEntity | (id: string) => Entity | undefined | Finds an entity by id. |
getEdge | (id: string) => Edge | undefined | Finds an edge by id. |
getConnectedEdges | (entityId: string) => Edge[] | Returns every edge that starts or ends at the entity. |
undo, redo | () => void | Step back or forward. They do nothing while history is off. |
canUndo, canRedo | boolean | Whether there's a step to take. |
History records the changes the canvas reports and the connections onConnect adds. The
edits you make yourself, through setEntities, setEdges and the four add and remove
functions, aren't recorded.
onConnect adds an edge every time it runs. It doesn't check whether the same two sockets are
already wired, or whether the input already has a wire. If your graph allows one wire per
input, write your own handler, as the next section does.
Apply changes in your own store
If the graph already lives somewhere else, such as a document store, a server or a
collaborative model, skip useGraph and apply the changes there. Each handler receives an
array of plain objects. Apply every object, in order:
graph-changes.tsimport { isTextEntity, resizableForSizingMode } from "@kushagradhawan/kookie-flow";import type { Connection, Edge, EdgeChange, Entity, EntityChange } from "@kushagradhawan/kookie-flow";export function applyEntityChanges(entities: Entity[], changes: EntityChange[]): Entity[] { const byId = new Map(entities.map((entity) => [entity.id, entity])); for (const change of changes) { if (change.type === "add") { byId.set(change.entity.id, change.entity); continue; } const entity = byId.get(change.id); if (!entity) continue; if (change.type === "remove") byId.delete(change.id); if (change.type === "position") byId.set(change.id, { ...entity, position: change.position }); if (change.type === "dimensions") byId.set(change.id, { ...entity, ...change.dimensions }); if (change.type === "parent") byId.set(change.id, { ...entity, parentId: change.parentId ?? undefined }); if (change.type === "data") { // Most toolbar controls send only the fields they change, so merge rather than replace. const next: Entity = { ...entity, data: { ...entity.data, ...change.data } }; // A text entity's resize handles follow its sizing mode. if (isTextEntity(next) && "sizingMode" in change.data) { next.resizable = resizableForSizingMode(next.data.sizingMode ?? "auto-height"); } byId.set(change.id, next); } } return [...byId.values()];}export function applyEdgeChanges(edges: Edge[], changes: EdgeChange[]): Edge[] { const removed = new Set<string>(); const added: Edge[] = []; for (const change of changes) { if (change.type === "remove") removed.add(change.id); if (change.type === "add") added.push(change.edge); } return [...edges.filter((edge) => !removed.has(edge.id)), ...added];}/** One wire per input: a new wire replaces the one already there. */export function connect(edges: Edge[], c: Connection): Edge[] { if (!c.source || !c.sourceSocket || !c.target || !c.targetSocket) return edges; const kept = edges.filter((e) => !(e.target === c.target && e.targetSocket === c.targetSocket)); const id = `${c.source}.${c.sourceSocket}->${c.target}.${c.targetSocket}`; return [...kept, { id, source: c.source, sourceSocket: c.sourceSocket, target: c.target, targetSocket: c.targetSocket, invalid: c.invalid }];}Then call them from the three handlers. This component keeps the document in useState; swap
setDoc for your own store's update function:
import { useCallback, useState } from "react";import { KookieFlow, type Connection, type EdgeChange, type EntityChange, type FlowObject } from "@kushagradhawan/kookie-flow";import { applyEdgeChanges, applyEntityChanges, connect } from "./graph-changes";export function DocumentEditor({ initial }: { initial: FlowObject }) { const [doc, setDoc] = useState(initial); const onEntitiesChange = useCallback((changes: EntityChange[]) => { setDoc((d) => ({ ...d, entities: applyEntityChanges(d.entities, changes) })); }, []); const onEdgesChange = useCallback((changes: EdgeChange[]) => { setDoc((d) => ({ ...d, edges: applyEdgeChanges(d.edges, changes) })); }, []); const onConnect = useCallback((connection: Connection) => { setDoc((d) => ({ ...d, edges: connect(d.edges, connection) })); }, []); return ( <KookieFlow entities={doc.entities} edges={doc.edges} defaultViewport={initial.viewport} onEntitiesChange={onEntitiesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} /> );}Three details decide whether a store stays in step with the canvas:
- Two batches can arrive in one tick. Delete reports the wires through
onEdgesChange, then the nodes throughonEntitiesChange, with no render between them. Update from the latest state, as the functionalsetDoccalls do, so the second batch doesn't overwrite the first. - Merge
data. A finished text edit and the sizing-mode control send the entity's wholedata, but most toolbar controls send only the fields they change. - Derive
resizablefor text. When a text entity'ssizingModechanges,useGraphalso sets itsresizablewithresizableForSizingMode, asapplyEntityChangesdoes above. Text and comments explains the three modes.
What each change describes
EntityChange and EdgeChange are unions. The type field tells you which fields the object
carries.
EntityChange
type | Fields | When the canvas sends it |
|---|---|---|
position | id, position: XYPosition | A node moved. |
dimensions | id, dimensions: Dimensions | A node was resized, or sized itself to its content. |
add | entity: Entity | An entity was created on the canvas. |
remove | id | An entity was deleted. |
data | id, data: EntityData | Text or a toolbar setting changed. |
parent | id, parentId: string | null | collapseToSubgraph or expandSubgraph moved an entity into or out of a group. |
select | id, selected: boolean | Never. The canvas keeps selection to itself. |
collapse | id, collapsed: boolean | Never. |
EdgeChange
type | Fields | When the canvas sends it |
|---|---|---|
add | edge: Edge | collapseToSubgraph or expandSubgraph rewired the graph. A new connection arrives through onConnect instead. |
remove | id | A wire was deleted, on its own or with its node. |
select | id, selected: boolean | Never. |
What is reported and what is not
These actions call your handlers. The canvas also applies each one to its own copy, with two
exceptions: the data from a finished text edit, and everything a toolbar style control
sends.
| Action | Reported as |
|---|---|
| Dragging nodes, when the pointer comes up | position for each node that moved |
| Shift with an arrow key, adding Alt for a step of ten | position |
Aligning or distributing, by key, toolbar, alignSelection or distributeSelection | position |
| Resizing a node | dimensions, plus position when the top or left edge moved |
| Delete or Backspace | remove for the wires, then remove for the nodes |
| Drawing a stroke, pressing T for a text entity, or dropping or pasting media that the canvas turns into an entity | add |
| Finishing a text edit that changed the text | data and dimensions |
| A toolbar style control | data, and for some controls dimensions and position |
| A picture, video or text entity fitting itself to its content | dimensions |
collapseToSubgraph or expandSubgraph | add, remove and parent, and edge add and remove |
| Dropping a wire on a socket that accepts it | a call to onConnect, not a change |
These change the canvas without calling any handler:
- Selection, by click, selection box, Cmd or Ctrl with A, or Escape. Read it with
getSelectedEntities()andgetSelectedEdges(). See Selection. - The view: panning, zooming,
fitViewandsetViewport. Read it withgetViewport(). - Widget values. A slider or text field on an input calls
onWidgetChange. See Sockets and widgets. toggleGroupCollapse,expandGroupandcollapseGroup. See Frames and groups.autoLayout. It moves the canvas's copy and returns the positions it chose, so pass them to your handler yourself. See Layout and alignment.useClipboardand every action you call on the store fromuseFlowStoreApi. See Clipboard and cloning and The flow store.- A dropped or pasted file when you pass
onFileDrop. The canvas creates nothing and hands you the files. See Dropping and pasting media.
This is how a controlled graph keeps the result of autoLayout:
const moves = flowRef.current?.autoLayout() ?? [];onEntitiesChange(moves.map(({ id, position }) => ({ type: "position", id, position })));Keep config props stable
The canvas compares some props by identity, so a new object on every render makes it redo work that changed nothing.
entitiesandedges. A new array makes the canvas rebuild its lookup tables, and the index it uses to find what's under the pointer, from scratch. Pass the array you already hold, and don't filter or map it inline.socketTypesandentityTypes. A newsocketTypesobject redraws every wire, socket and widget.entityTypesis compared definition by definition, so a new table that holds the same definition objects costs nothing, but a definition written inline resolves every entity again. Define both once, outside the component.
font and entityStyle are compared by their contents, so writing them inline is fine.
// Rebuilds on every render: a new socketTypes object and a new entities array each time.function SlowBoard({ entities, edges }: { entities: Entity[]; edges: Edge[] }) { return ( <KookieFlow socketTypes={{ texture: { name: "Texture", color: "--purple-10" } }} entities={entities.filter((entity) => !entity.data.archived)} edges={edges} /> );}// Rebuilds only when `entities` changes.const socketTypes = { texture: { name: "Texture", color: "--purple-10" } };function Board({ entities, edges }: { entities: Entity[]; edges: Edge[] }) { const visible = useMemo(() => entities.filter((entity) => !entity.data.archived), [entities]); return <KookieFlow socketTypes={socketTypes} entities={visible} edges={edges} />;}