Skip to Content

Hooks

🎯

Goal: Learn how to control and observe cascades from UI nodes using Cascaide’s two primary hooks.

There are two hooks that allow you to control and observe cascades from UI nodes:

  • useWorkflow — for control
  • useCascade — to observe
  • useAllCascades — to get allcascadeIds

useWorkflow

useWorkflow is the lifecycle hook for UI nodes. It gives you everything you need to read node state, write persistent context, spawn child nodes, and signal completion — nothing more.

const { nodeData, getState, updateContext, addActiveNode, signalCompletion, } = useWorkflow(nodeId);

If you need to read cascade state for display purposes outside of a UI node, see useCascade.


Parameters

nodeId: string

The unique identifier of the active node instance. Cascaide passes this to your UI node component automatically — pass it directly into useWorkflow without modification.

export default function MyUINode({ nodeId }: { nodeId: string }) { const { nodeData, signalCompletion } = useWorkflow(nodeId); }

Return Values

nodeData

nodeData: ActiveNode | undefined

The full active node record from the store for this node instance. This is your primary way to access the context that was passed when this node was spawned.

type ActiveNode = { nodeName: string; parentTriggerId?: string; processed?: boolean; initialContext?: any; origin?: 'client' | 'server'; functionId?: number; cascadeId?: string; };
const { nodeData } = useWorkflow(nodeId); const cascadeId = nodeData?.cascadeId; const userId = nodeData?.initialContext?.userId; const history = nodeData?.initialContext?.history;

initialContext is whatever was passed in the spawn context when this node was dispatched. It contains the cascade history, userId, and any custom keys your server node put in the spawn.


getState

getState: () => RootState

An escape hatch that returns the full local Redux state at the time of the call. Use this for advanced cases where you need to read state outside of React’s render cycle — for example, inside an event handler that needs a snapshot of the current store.

const { getState } = useWorkflow(nodeId); const handleSubmit = () => { const state = getState(); const allActiveNodes = state.workflow.activeNodes; // ... };

For reactive state reading inside render, use useCascade instead — getState does not subscribe to store updates.


updateContext

updateContext: (updates: Updates) => Promise<void>

Writes context updates to the store and triggers the persistence middleware. Only available on persisted nodes — nodes that have both a cascadeId and an origin.

type Updates = { [cascadeId: string]: WorkflowStep; };
const { nodeData, updateContext } = useWorkflow(nodeId); await updateContext({ [nodeData.cascadeId]: { history: [{ role: 'user', content: userInput }], status: 'ready', } });

Ephemeral nodes: If called on a node without a cascadeId, updateContext is a no-op. In development, a warning is logged to help catch accidental calls.

Use updateContext when the context change needs to be durable — visible to downstream server nodes and recoverable after a crash. For local component state that doesn’t need to persist, use React’s useState.


addActiveNode

addActiveNode: (spawns: Spawns) => Promise<Record<string, string> | undefined>

Spawns one or more nodes from within a UI node. The dispatched actions pass through the client middleware stack, handling persistence and hydration before the listener picks them up.

type Spawns = Record<string, SpawnContext>; type SpawnContext<T = Record<string, any>> = { cascadeId?: string; history: any; userId?: string; } & T;

Though userId is marked optional, you have to pass it in if cascadeId is. If you do not, the addActiveNode invocation will be blocked. The reason for this is that these types are shared by the server side as well, and there, userId is inherited from the predecessor node in the cascade. This is a TODO.

const { nodeData, addActiveNode, signalCompletion } = useWorkflow(nodeId); const handleApprove = async () => { await addActiveNode({ processApprovalNode: { cascadeId: nodeData?.cascadeId, history: nodeData?.initialContext?.history, userId: nodeData?.initialContext?.userId, approved: true, } }); await signalCompletion(true); };

If a cascadeId is provided in the spawn context, addActiveNode returns a map of nodeName → cascadeId for all spawned nodes. Returns undefined if no cascadeIds were provided.

const cascadeMap = await addActiveNode({ agentA: { cascadeId: 'cascade_001', history: [], userId }, agentB: { cascadeId: 'cascade_002', history: [], userId }, }); // cascadeMap => { agentA: 'cascade_001', agentB: 'cascade_002' }

signalCompletion

signalCompletion: (hasSpawns: boolean, fullOutput?: any) => Promise<void>

Removes this node from active nodes, signalling that the UI node has finished. You must always call this — without it the node stays in activeNodes indefinitely and the workflow stalls.

// Ephemeral node — no output to record await signalCompletion(false); // Persisted node completing without spawns await signalCompletion(false, { userDecision: 'approved', timestamp: Date.now(), }); // Persisted node completing with spawns already dispatched await signalCompletion(true, { userDecision: 'approved', });

hasSpawns: Set to true if you called addActiveNode before completing. Set to false if this node is terminal or the workflow continues via a server-side spawn.

fullOutput: The recorded output of this node execution. Required for persisted nodes — in development, a warning is logged if omitted on a persisted node. Not needed for ephemeral nodes.

Always call signalCompletion — even on cancel, dismiss, or error paths. A finally block is a good pattern.

try { await addActiveNode({ ... }); await signalCompletion(true, { result }); } catch (err) { await signalCompletion(false); }

Persisted vs Ephemeral Nodes

useWorkflow behaves differently depending on whether the node is persisted. A node is considered persisted if it has both a cascadeId and an origin in its nodeData.

PersistedEphemeral
updateContextWrites to store + persistence middlewareNo-op, dev warning
signalCompletion without fullOutputDev warningSilent
Spawned nodesInherit cascadeIdNo cascadeId

You don’t need to check this yourself — the hook handles it internally.


Complete Example

A HITL approval node that reads initial context, presents a decision UI, and either approves (spawning the next node) or rejects (completing without spawning).

'use client'; import { useWorkflow } from '@/hooks/useWorkflow'; export default function ApprovalNode({ nodeId }: { nodeId: string }) { const { nodeData, addActiveNode, signalCompletion } = useWorkflow(nodeId); const cascadeId = nodeData?.cascadeId; const userId = nodeData?.initialContext?.userId; const history = nodeData?.initialContext?.history ?? []; const summary = nodeData?.initialContext?.summary ?? 'No summary available.'; const handleApprove = async () => { await addActiveNode({ executeActionNode: { cascadeId, userId, history, approved: true, } }); await signalCompletion(true, { approved: true }); }; const handleReject = async () => { await signalCompletion(false, { approved: false }); }; return ( <div> <p>{summary}</p> <button onClick={handleApprove}>Approve</button> <button onClick={handleReject}>Reject</button> </div> ); }

Type Reference

type ActiveNode = { nodeName: string; parentTriggerId?: string; processed?: boolean; initialContext?: any; origin?: 'client' | 'server'; functionId?: number; cascadeId?: string; }; type SpawnContext<T = Record<string, any>> = { cascadeId?: string; history: any; userId?: string; } & T; type Spawns = Record<string, SpawnContext>; type Updates = { [cascadeId: string]: WorkflowStep; }; type WorkflowStep = { history: any; status: string; [extraKeys: string]: any; };

useCascade

useCascade is the primary hook for reading cascade state in your UI. Use it anywhere you need to display agent output, track cascade progress, or trigger time-travel operations — inside or outside of UI nodes.

const { cascadeState, cascadeNodes, isComplete, exists, forkCascade, } = useCascade(cascadeId);

For spawning nodes or signalling completion from within a UI node, see useWorkflow.


Parameters

cascadeId: string

The ID of the cascade you want to observe. This is typically the cascadeId from a UI node’s nodeData, a cascadeId stored in your component’s state, or one returned by useAllCascades.

const { cascadeState } = useCascade('cascade_abc123');

Return Values

cascadeState

cascadeState: { status: string; history: any[]; [key: string]: any } | undefined

The assembled state of the cascade, collected across all node executions. Returns undefined if the cascade does not exist yet.

status — the status string from the latest node execution. Common values are 'streaming', 'calling_tool', 'completed', but these are defined by your own server nodes.

history — all history entries collected across every node execution in order. This is what you pass directly to your chat UI.

Extra keys — any additional keys written by your server nodes are collected across all steps that wrote them, in order.

const { cascadeState } = useCascade(cascadeId); // Drive a chat UI const messages = cascadeState?.history ?? []; // Check agent status const status = cascadeState?.status; // Access custom keys written by your server nodes // e.g. a node wrote { reportData: {...} } to the cascade const reports = cascadeState?.reportData ?? [];

How extra keys work:

If node 1 writes { history: [m1], x: valueA, y: valueB } and node 2 writes { history: [m2], y: valueC } and node 3 writes { history: [m3], x: valueD }, you get:

{ status: 'completed', // from latest node history: [m1, m2, m3], // collected across all nodes x: [valueA, valueD], // collected from nodes that wrote x y: [valueB, valueC], // collected from nodes that wrote y }

If you’re unsure what keys are available, check Redux DevTools — the full store shape is visible there during development.


cascadeNodes

cascadeNodes: CascadeNode[]

All currently active nodes for this cascade. An empty array means no nodes are running.

type CascadeNode = { nodeId: string; nodeName: string; parentTriggerId?: string; initialContext?: any; processed?: boolean; };
const { cascadeNodes } = useCascade(cascadeId); // Show a loading indicator while nodes are active const isRunning = cascadeNodes.length > 0; // See which nodes are currently executing const nodeNames = cascadeNodes.map(n => n.nodeName);

isComplete

isComplete: boolean

true when the cascade has state in the store but no active nodes — meaning all nodes have finished executing. Useful for triggering post-completion UI or effects.

const { isComplete, cascadeState } = useCascade(cascadeId); useEffect(() => { if (isComplete) { // All agents done, show final result setResult(cascadeState?.history ?? []); } }, [isComplete]);

exists

exists: boolean

true if the cascade has been created — either state exists in the store or nodes are currently active. Use this to conditionally render cascade-dependent UI without relying on cascadeState being defined.

const { exists, cascadeState } = useCascade(cascadeId); if (!exists) return <EmptyState />; if (!cascadeState) return <LoadingState />; return <CascadeUI history={cascadeState.history} />;

forkCascade

forkCascade: ( newCascadeId: string, upToFunctionId: number, sourceCascadeId?: string, ) => Promise<{ status: 'SUCCESS' | 'FAILED' }>

Creates a fork of a cascade up to a specific point in its execution history. The forked cascade gets a new cascadeId and contains all context up to and including the given functionId.

newCascadeId — the ID for the new forked cascade. Generate this with your preferred ID strategy.

upToFunctionId — the functionId up to which the cascade state should be copied. Node executions are stamped with incrementing functionIds — fork at 2 to get everything up to and including the third node execution.

sourceCascadeId — optionally fork a different cascade than the one the hook is scoped to. Defaults to the hook’s cascadeId if not provided.

const { forkCascade } = useCascade(cascadeId); // Fork this cascade at functionId 2 const result = await forkCascade('cascade_fork_001', 2); if (result.status === 'SUCCESS') { // New cascade exists at 'cascade_fork_001' }

Forking from a UI node with multiple cascadeIds:

A common pattern is a UI node that receives a list of cascadeIds in its initialContext and lets the user choose which one to fork and from what point.

export default function TimeTravelNode({ nodeId }: { nodeId: string }) { const { nodeData, signalCompletion } = useWorkflow(nodeId); const sourceCascadeId = nodeData?.initialContext?.targetCascadeId; // Hook scoped to current cascade, but forking a different one const { forkCascade } = useCascade(nodeData?.cascadeId); const handleFork = async (upToFunctionId: number) => { const newCascadeId = `cascade_${Date.now()}`; const result = await forkCascade(newCascadeId, upToFunctionId, sourceCascadeId); if (result.status === 'SUCCESS') { await signalCompletion(false, { forkedCascadeId: newCascadeId }); } }; return <ForkUI onFork={handleFork} />; }

useAllCascades

useAllCascades: () => string[]

Returns all cascade IDs currently in the store. Useful for building dashboards, observability UIs, or any component that needs to enumerate all active or completed cascades.

import { useAllCascades, useCascade } from '@/hooks/useCascade'; function CascadeDashboard() { const cascadeIds = useAllCascades(); return ( <div> {cascadeIds.map(id => ( <CascadeRow key={id} cascadeId={id} /> ))} </div> ); } function CascadeRow({ cascadeId }: { cascadeId: string }) { const { cascadeState, isComplete } = useCascade(cascadeId); return ( <div> <span>{cascadeId}</span> <span>{isComplete ? 'Done' : cascadeState?.status ?? 'Pending'}</span> </div> ); }

Type Reference

type CascadeNode = { nodeId: string; nodeName: string; parentTriggerId?: string; initialContext?: any; processed?: boolean; }; type CascadeStateResult = { status: string; history: any[]; [key: string]: any; };

Now we know how to observe and control graphs from the client. In the next section, we will learn about defining nodes.

Last updated on