We're building a workflow builder. A canvas where you drag in a node, point it at a frontier model, feed it some input, and say what shape you want the output in. Nodes connect to nodes. One node's output is the next one's input.
Simple on paper. In practice it means a lot of state changing at once, read by a lot of components, sixty times a second while somebody is dragging.
Getting that wrong is what made the canvas feel slow. This is the write-up of how we got it right, and why the answer was decided by a library we had already installed.
Two stores, one canvas#
Before any of the library choice mattered, we had already split the state in two.
- The editing store — everything about building the workflow. Canvas zoom, node positions, node configs, edges, which node is selected.
- The execution store — everything about running it. Which node is executing, what is pending, what finished, and the output of each node.
The reason for the split is a product requirement: you can edit a workflow while it is executing. If both lived in one blob, every polled execution update would land on the same object the editor is writing to, and the two would fight.
Round one: React Context#
We reached for what was closest. Two providers, one per store, everything inside.
// The shape that seemed reasonable at the time.
type EditingState = {
nodes: Node[];
edges: Edge[];
viewport: Viewport;
selectedNodeId: string | null;
nodeConfigs: Record<string, NodeConfig>;
updateNodeConfig: (id: string, patch: Partial<NodeConfig>) => void;
};
const EditingContext = createContext<EditingState | null>(null);
export function EditingProvider({ children }: { children: ReactNode }) {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [viewport, setViewport] = useState<Viewport>(DEFAULT_VIEWPORT);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [nodeConfigs, setNodeConfigs] = useState<Record<string, NodeConfig>>({});
const updateNodeConfig = useCallback((id: string, patch: Partial<NodeConfig>) => {
setNodeConfigs((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
}, []);
// Every consumer re-renders when any one of these changes.
const value = useMemo(
() => ({ nodes, edges, viewport, selectedNodeId, nodeConfigs, updateNodeConfig }),
[nodes, edges, viewport, selectedNodeId, nodeConfigs, updateNodeConfig],
);
return <EditingContext.Provider value={value}>{children}</EditingContext.Provider>;
}A node component then did useContext(EditingContext) and pulled out the one
field it cared about.
That last sentence is the whole bug. It pulls one field out after it has already subscribed to all of them.
This worked fine for a demo-sized workflow.
The cracks showed at about 30 nodes#
Once real workflows hit 30–40 nodes, especially with an execution running alongside, you could feel it:
- dragging a node felt sticky, like the canvas had resistance
- zooming stuttered
- the whole thing felt heavy
We profiled, and it was not subtle. Changing one value on one node was re-rendering every other node, the sidebar that holds all the node configs, and every panel and button reading from that context. Measured: 20 to 50 re-renders for a single node moving.
This is the classic Context trap: a component that consumes a context re-renders whenever any value in that context changes, even if it only reads one field. With 30+ nodes all subscribed to the same provider, one small update rippled out to everything.
We tried to fix Context before replacing it#
Worth saying, because "we swapped libraries" is a bad story if you skipped the cheap fix.
Two attempts:
- Split the single Context into smaller providers — down to one provider per node, so a node's update should only touch that node's provider.
- Wrap consumers in
memoso an unchanged subtree bails out.
Neither held. Changing one node still re-rendered the other nodes and their
internals. That is the nature of Context: anything reading a provider re-renders
when that provider's value changes, and memo cannot help you when the value the
component is subscribed to is a new object on every update. You can slice
providers as thin as you like, but the moment they share a parent that re-renders,
you are back where you started.
So we stopped patching and went looking for a store built for this.
Redux or Zustand#
We knew what we needed: selector-based subscriptions, where a component re-renders only if the specific slice it reads actually changed. Two candidates.
| Criteria | Redux (+ React-Redux) | Zustand |
|---|---|---|
| Setup effort | Store, reducers, actions, provider | One hook, basically done |
| Selective re-rendering | Possible, with careful selector discipline | Built in, selector-first by default |
| Learning curve for the team | More concepts to onboard onto | Feels like using a hook |
| Time to implement, at our stage | High | Low |
| Fit for current product stage | Overkill for the problem we had | Matches the complexity we have |
Redux could absolutely have solved this. With React-Redux and disciplined selectors, it is the same fix. But we were not looking for an architecture, we were looking to stop a re-render storm, and the machinery was not worth it yet.
Zustand gave us most of the benefit for a fraction of the setup.
The reason that actually decided it#
Our canvas is React Flow. And React Flow's own internal state — node positions, viewport, drag state, selection, connections — is Zustand. Not a coincidence we noticed later; it is how React Flow tells you to manage custom node state once you are past a handful of nodes.
That matters because of what we were doing wrong, which was not "we picked the wrong library". It was that two things owned the same data.
React Flow keeps the graph in its store because it draws the canvas. We kept the graph in Context too, in our own shape. So something had to transform between the two, and then keep them agreeing:
To stop the loop you add a guard flag ("this update came from me, ignore it") and a hash check (is this incoming array actually different, or just a new array with the same contents). Which means serialising the whole nodes array on every render, twice. Which is why position updates got debounced by 300ms. Which is why, while you dragged, React Flow painted the node under your cursor immediately and everything reading our state was a third of a second behind.
What the fix looked like#
We did not add a state library. We stopped being the second owner.
Before
Context → transform → sync → React Flow, and back again. Two shapes, one guard flag, one hash, one debounce.
After
One Zustand store that React Flow talks to directly. Both shapes updated in the same action. Nothing pushed back in.
The store owns the change handler, so React Flow's report is the update — there is no second write to echo back:
import { create } from "zustand";
import { applyNodeChanges, type Node, type NodeChange } from "@xyflow/react";
type EditingStore = {
nodes: Node<NodeData>[];
edges: Edge[];
// React Flow calls this directly. No effect, no sync, no guard flag.
onNodesChange: (changes: NodeChange[]) => void;
updateNodeConfig: (id: string, patch: Partial<NodeConfig>) => void;
};
export const useEditingStore = create<EditingStore>((set) => ({
nodes: [],
edges: [],
onNodesChange: (changes) =>
set((state) => ({ nodes: applyNodeChanges(changes, state.nodes) })),
updateNodeConfig: (id, patch) =>
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, config: { ...node.data.config, ...patch } } }
: node,
),
})),
}));And a node component subscribes to its own slice, not to the array:
function DynamicNode({ id }: NodeProps) {
// Re-renders when THIS node's config changes. Not when node 12 moves.
const config = useEditingStore((s) => s.nodes.find((n) => n.id === id)?.data.config);
const status = useExecutionStore((s) => s.statusByNode[id]);
const updateNodeConfig = useEditingStore((s) => s.updateNodeConfig);
return <NodeShell status={status} config={config} onChange={(p) => updateNodeConfig(id, p)} />;
}That second line is the whole point of the migration. Node 12's status arriving
from a poll touches node 12's component. Nodes 1 through 11 do not hear about it —
they never subscribed to statusByNode as a whole, only to their own key.
The rest fell out of it:
- nothing is pushed back into React Flow, so there is no loop
- no loop, so no guard flag and no hash
- the 300ms debounce went away, because there was nothing left to protect
- when we need React Flow's own internals,
useStoreanduseStoreApiare right there — the same store, not a black box behind a Context
Do the two stores talk to each other?#
They have to. A canvas node shows its live execution status right on the node — running, done, failed, and eventually its output. So the canvas reads from both.
The catch that shaped this design: the user can keep editing — even delete — a node while it is still executing. That is exactly why execution is not folded into the editing store.
- The editing store stays focused on what the workflow is: nodes, edges, configs, positions.
- The execution store tracks what is happening: status, progress, output, kept in sync by polling.
- If a node is deleted mid-execution, the editing store drops it immediately. The execution store keeps polling in the background and quietly ignores results for nodes that no longer exist.
If those lived together, deleting a running node would mean untangling one from the other on the hot path. They do not, so it does not.
The lag at 30–40 nodes went away. A drag settles in a frame instead of 350ms, one node moving costs 1 re-render instead of 20–50, and about 150 lines of sync, guard-flag and hashing code were deleted rather than rewritten. Editing and execution now run side by side without either dragging the other down.
What I would tell someone before they start#
Context is not bad. It is excellent for state that rarely changes — theme, auth, locale, a feature flag. It is a dependency-injection tool that happens to hold state.
For state that updates often and is read by many components — a canvas with
dozens of interactive nodes — you need granular subscriptions from day one, and no
amount of provider-splitting or memo will retrofit them.
And the part I would say first: if the library you build on has already made a state decision, the cheapest path is usually to make the same one. We did not choose Zustand on its merits alone. We chose to stop fighting a store that was already running underneath our canvas.
The wider version of this story — the workflow platform, the execution engine, the QC that stopped humans reviewing ten thousand images — is in Foosh: From One Prompt Box to Ten Thousand Rows.