All posts
Tech

Why We Use Zustand with React Flow to Manage State

Our AI workflow canvas got laggy at 30 nodes. Splitting the React Context did not fix it, and memo did not either. This is why we moved to Zustand, why Redux lost, and why React Flow already made the decision for us.

12 min readMukesh Bishnoi
ReactZustandReact FlowReact ContextPerformanceState Management

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.

30–40 nodes before it got laggy
20–50 re-renders to drag one node
300ms debounce we could delete
2 stores, kept separate on purpose

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.

REACT CONTEXT ZUSTAND SELECTORS n1n2n3 n4n5n6 sidebar · inspector · toolbar n1n2n3 n4n5n6 sidebar · inspector · toolbar edit n5 → 7 components re-render edit n5 → 1 component re-renders
Same edit, same canvas. The only difference is how components subscribe. Filled = re-rendered.

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:

  1. 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.
  2. Wrap consumers in memo so 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:

WHILE DRAGGING, THIS RUNS 60×/SECOND React Flow store onNodesChange context setState transform + hash setNodes(nextNodesArray) React Flow sees a new array, reports a change, and we go round again.
The loop. The guard flag and the hash check existed only to break it — about 150 lines whose entire job was keeping two copies of the graph 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, useStore and useStoreApi are 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.

EDITING STORE EXECUTION STORE nodes + edges positions · viewport · zoom node config per node selection status per node pending · running · done output per node kept fresh by polling one canvas node its config its status
Separate stores, read together at the leaf. Each node subscribes to its own key in each store, not to either store as a whole.

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.

Outcome

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.

Common questions

Why use Zustand with React Flow instead of React Context?

Because React Flow already stores its own graph state in Zustand. Node positions, viewport, drag state, selection and connections all live in a Zustand store inside the library. If you keep your node data in React Context as well, two systems own the same data in two different shapes and you have to write a transform and a sync layer between them. Putting your state in Zustand removes that layer: React Flow's change handler becomes an action on your own store, both shapes are updated in the same call, and components subscribe per node instead of per provider.

Why does a React Flow canvas get slow when you add more nodes?

Usually it is not React Flow, it is how the node data is subscribed to. If every custom node reads from one React Context, or from the whole nodes array, then changing one node re-renders all of them plus the sidebar and any panel reading the same value. On our AI workflow builder that showed up at around 30 to 40 nodes as sticky dragging and stuttering zoom, and profiling showed 20 to 50 re-renders for a single node being moved. The fix is granular subscriptions, so a component only re-renders when the specific slice it reads actually changes.

How do you update one node's data in React Flow without re-rendering every node?

Keep the per-node data in a Zustand store keyed by node id, and have each custom node component select only its own key. The default approach — call setNodes on the whole array to change one node's data — makes React Flow reconcile the entire array, and any node component reading that array re-renders with it. With a store, updating node 12's config or execution status touches node 12's component only; nodes 1 to 11 never subscribed to it.

Is React Context bad for state that changes often?

It is the wrong tool for it. React Context has no concept of a partial subscription: any component that consumes a context re-renders whenever any value in that context changes, even if it only reads one field of it. That is fine for state that rarely changes — theme, auth, locale, feature flags — and it falls apart for state written many times a second, like a canvas being dragged. For that you need a store with selector-based subscriptions, such as Zustand, Redux with React-Redux, or Jotai.

Does splitting React Context into smaller providers fix re-render problems?

Not on its own. We tried it before switching libraries — one provider per node, with memoised consumers — and changing one node still re-rendered the other nodes and their internals. Slicing providers thinner does not change the rule that everything reading a provider re-renders when that provider's value changes, and React.memo cannot help when the value a component is subscribed to is a new object on every update. It is worth trying because it is cheap, but do not plan around it working.

Zustand or Redux for a node editor or canvas app?

Both can solve the re-render problem, because both support selector-based subscriptions. We chose Zustand for three reasons: creating a store is a few lines with no provider or reducer wiring, selector subscriptions are the default rather than something you have to be disciplined about, and React Flow's own internal store is Zustand, so there is one state model in the app instead of two. Redux earns its boilerplate on larger apps with many teams and strict action logging; for fixing a canvas re-render storm it was a slower, heavier route to the same place.

Should editing state and execution state be in the same store?

Keep them separate if the user can edit while something is running. We use one Zustand store for editing — nodes, edges, positions, configs — and a second for execution — status, progress and output per node, kept fresh by polling. The reason is a product requirement: you can edit, and even delete, a node while it is still executing. With separate stores the editor drops a deleted node immediately and the execution store just ignores results for nodes that no longer exist. In one store, every polled update would be writing to the object the editor is writing to.

What state does React Flow keep internally, and can you read it?

React Flow keeps node positions, the viewport and zoom, drag state, selection and in-progress connections in an internal Zustand store. You can read it directly with its useStore hook for reactive access, or useStoreApi when you need the current value without subscribing. That is one of the practical advantages of using Zustand for your own state too: the library's state and your state are the same kind of thing, rather than one being a black box behind a Context you cannot select into.

How many nodes can React Flow handle before performance becomes a problem?

There is no fixed number, because the ceiling is set by how your node components subscribe to data rather than by the library. With every node reading one React Context we felt it at 30 to 40 nodes, especially with an execution running at the same time. After moving the same graph to Zustand with per-node selectors, that lag disappeared and the 300ms position debounce we had added to hide it was deleted. Fix subscriptions first before reaching for virtualisation or node culling.

Thanks for reading. Questions or pushback are welcome — I'm reachable on LinkedIn.

All posts