This article highlights best practices for state management, along with practical use cases for different scenarios.
State management in React Flow applications is a critical factor that directly impacts user interface performance and responsiveness. Poorly structured state can cause unnecessary re-renders, slowing down the app and frustrating users – especially in complex systems with large data sets and lots of interactions.
This article highlights best practices for state management, along with practical use cases for different scenarios.
The simplest way to manage state in React Flow is to rely on the library’s built-in mechanisms – known as uncontrolled state. In this model, all interactions with nodes and edges are handled internally by React Flow. This makes implementation easier, as you don’t need to pass down action handlers or install any extra libraries.
useReactFlow()
is a hook provided by the library. It’s a handy tool for accessing and controlling the state of your diagram – including nodes, edges, and viewport settings. It gives you access to the internal React Flow instance, which comes with a variety of useful methods and properties.
Here’s a basic example of a component that adds a new node to the diagram:
// AddNewNodeButton.tsx
import { useReactFlow } from '@xyflow/react';
const defaultNode = {
position: { x: 0, y: 0 },
type: 'default',
data: { label: 'New Node' },
};
export const AddNewNodeButton = () => {
const { addNodes } = useReactFlow();
const handleAddNode = () => {
addNodes({ ...defaultNode, id: crypto.randomUUID() });
};
return <button onClick={handleAddNode}>Add new node</button>;
};
The useReactFlow()
hook includes two helpful methods for accessing node data:
getNodes()
– Returns all nodes currently in the diagram, getNode(id: string)
– Returns the specific node with the given ID. The getNode()
offers constant O(1)
access time to a node, regardless of how many nodes exist. That makes it far more efficient than manually filtering arrays.
Similarly, you can use getEdges()
and getEdge(id: string)
to access edges.
To remove elements from the diagram, you can use the built-in deleteElements
method returned by useReactFlow
hook. This method accepts an object like this:
{
nodes?: (Partial<Node> & { id: Node['id'] })[],
edges?: (Partial<Edge> & { id: Edge['id'] })[]
}
It allows you to remove either nodes or edges. At a minimum, you need to pass an array with the id of the elements to delete.
When removing nodes, the method automatically deletes their connected edges and any child elements in grouped structures.
// RemoveNodeWithEdgesButton.tsx
import { useReactFlow } from '@xyflow/react';
export const RemoveNodeWithEdgesButton = ({ nodeId } : { nodeId: string }) => {
const { deleteElements } = useReactFlow();
const handleRemoveNodeWithEdges = () => {
deleteElements({ nodes: [{ id: nodeId }] });
};
return <button onClick={handleRemoveNodeWithEdges}>Remove node</button>;
};
To update nodes and edges, you can use the built-in hooks useNodesState()
and useEdgeState()
from the React Flow library. They’re similar to React’s native useState()
hook, but extended with an additional helper:
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
Both hooks return the current array of elements (like nodes
), a setter function (like setNodes
), and a callback that handles changes (like onNodesChange
). When initializing the hook, you provide the initial array of elements – for example, initialNodes
.
Here’s a sample implementation using these hooks to update nodes and edges:
// App.tsx
import { ReactFlow, useNodesState, useEdgesState, Panel, Node, Edge } from '@xyflow/react';
import { useMemo } from 'react';
type MyNode = Node & {
data: {
label: string;
};
};
const initialNodes = [
{ id: '1', data: { label: 'New Node' }, position: { x: 100, y: 100 } },
{ id: '2', data: { label: 'New Node 2' }, position: { x: 100, y: 200 } },
] as MyNode[];
const initialEdges = [{ id: 'e1-2', source: '1', target: '2' }] as Edge[];
export function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const selectedNode = useMemo(() => {
const selected = nodes.filter((node) => node.selected);
return selected?.[0] ?? null;
}, [nodes]);
const updateNodeLabel = (newLabel: string) => {
if (!selectedNode) return;
setNodes((nds) =>
nds.map((node) => (node.id === selectedNode.id ? { ...node, data: { ...node.data, label: newLabel } } : node)),
);
};
const updateNodeHiddenState = (newHidden: boolean) => {
if (!selectedNode) return;
setNodes((nds) => nds.map((node) => (node.id === selectedNode.id ? { ...node, hidden: newHidden } : node)));
setEdges((eds) =>
eds.map((edge) =>
edge.source === selectedNode.id || edge.target === selectedNode.id ? { ...edge, hidden: newHidden } : edge,
),
);
};
return (
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}>
{selectedNode && (
<Panel position="top-left" style={{ width: 200 }}>
<label>Label:</label>
<input value={selectedNode.data.label} onChange={(evt) => updateNodeLabel(evt.target.value)} />
<label>Hidden:</label>
<input
type="checkbox"
checked={selectedNode.hidden ?? false}
onChange={(evt) => updateNodeHiddenState(evt.target.checked)}
/>
</Panel>
)}
</ReactFlow>
);
}
In the example above, we update the label and hidden properties of a node, and also toggle the visibility of an edge based on the node’s visibility.
In the Uncontrolled state section, we covered how React Flow can work with local component state to manage nodes and edges in the diagram. However, as your application grows, managing that state can become more complex. To make things easier in larger apps, you can use React Context or integrate a state management library.
When opting for an external library in a React app, there are several options to choose from. For React Flow, Zustand and Redux are among the most popular choices. If your state is less complex, a lightweight library like Jotai may also be a good fit.
In the following examples, we’ll use Zustand – which is also used internally by React Flow itself.
First, define the type that describes your state:
// use-diagram-store.ts
import { Node, Edge, NodeChange, EdgeChange, Connection } from '@xyflow/react';
export type DiagramState = {
nodes: Node[];
edges: Edge[];
reactFlowInstance: ReactFlowInstance | null;
selectedNodes: Node[];
setSelectedNodes: (nodes: Node[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
onInit: (instance: ReactFlowInstance) => void;
};
Then, use create
method to initialize your store with default values:
// use-diagram-store.ts
import { create } from 'zustand';
import { applyNodeChanges, applyEdgeChanges, addEdge } from '@xyflow/react;
export const useDiagramStore = create<DiagramState>((set) => ({
nodes: [],
edges: [],
reactFlowInstance: null;
selectedNodes: [],
setSelectedNodes: (nodes) => set({ selectedNodes: nodes }),
onNodesChange: (changes) => set((state) => ({ nodes: applyNodeChanges(changes, state.nodes) })),
onEdgesChange: (changes) => set((state) => ({ edges: applyEdgeChanges(changes, state.edges) })),
onConnect: (connection) => set((state) => ({ edges: addEdge(connection, state.edges) })),
onInit: (instance) => {
(window as any).diagram = instance; // optionally, for testing purposes
set({ reactFlowInstance: instance });
},
}));
The create method returns a custom hook you can use across your app. Here’s how to access individual methods or values from the store:
const selectedNodes = useDiagramStore((state) => state.selectedNodes);
To access multiple values from store at once, use this approach:
const [selectedNodes, someOtherProperty] = useDiagramStore((state) => [
state.selectedNodes,
state.someOtherProperty,
]);
Just make sure your store is properly configured so it doesn't hurt your app’s performance. For more on this topic, see The ultimate guide to optimize React Flow project performance and the Optimization section.
To make our Zustand store respond to diagram changes – and vice versa – we need to pass its methods and values into the ReactFlow
component:
// Workspace.tsx
import { useDiagramStore } from '@/store';
import { ReactFlow, Background } from '@xyflow/react';
export const Workspace = () => {
// ... rest of the component
const [
nodes, edges, onInit, onNodesChange, onEdgesChange, onConnect
] = useDiagramStore((state) => [
state.nodes, state.edges, state.onInit, state.onNodesChange, state.onEdgesChange, state.onConnect
]);
return (
<ReactFlow
onInit={onInit}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
// some other props
>
<Background />
</ReactFlow>
);
};
The ReactFlow component comes with many more methods and properties. You can find the full list here.
To add nodes through the store, define a method that modifies the nodes array:
// use-diagram-store.ts
import { getNodeAddChange } from '@/utils/get-node-add-change';
export type DiagramState = {
// ... rest of the type
addNodes: (nodes: Node[]) => void;
};
export const useDiagramStore = create<DiagramState>((set, get) => ({
// ... rest of the store
addNodes: (nodes) => set((state) => ({ nodes: [...state.nodes, ...nodes] }))
}));
Alternatively, instead of using a callback function, you can use the get method:
addNodes: (nodes) => set({ nodes: get().nodes.concat(nodes) })
If your app fires off many rapid, concurrent actions to add nodes, the callback approach offers better protection against missing updates. For simpler, less frequent calls, both approaches should behave the same way.
Here’s an example component that adds a new node to the diagram:
// AddNewNodeButton.tsx
const defaultNode = {
position: { x: 0, y: 0 },
type: 'default',
data: { label: 'New Node' },
};
export const AddNewNodeButton = () => {
const addNodes = useDiagramStore((store) => store.addNodes);
const handleAddNode = () => {
addNodes([{ ...defaultNode, id: crypto.randomUUID() }]);
};
return <button onClick={handleAddNode}>Add New Node</button>;
};
When setting up the ReactFlow
component, we’ve already connected the onNodesChange
and onEdgesChange
handlers. That means we don’t need to define extra methods for removing elements. We can simply reuse the deleteElements
method – just like in the Uncontrolled state. One key advantage here is that React Flow automatically removes any orphaned edges and child elements from deleted groups. This setup also enables support for other deletion methods, like pressing the Backspace key.
The example below works the same way as in the uncontrolled state section – the removal is now fully integrated with our state.
// RemoveNodeWithEdgesButton.tsx
import { useReactFlow } from '@xyflow/react';
export const RemoveNodeWithEdgesButton = ({ nodeId } : { nodeId: string }) => {
const { deleteElements } = useReactFlow();
const handleRemoveNodeWithEdges = () => {
deleteElements({ nodes: [{ id: nodeId }] });
};
return <button onClick={handleRemoveNodeWithEdges}>Remove node</button>;
};
Let’s look at the current definition of our state:
// use-diagram-store.ts
export type DiagramState = {
nodes: Node[];
edges: Edge[];
reactFlowInstance: ReactFlowInstance | null;
selectedNodes: Node[];
setSelectedNodes: (nodes: Node[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
onInit: (instance: ReactFlowInstance) => void;
addNodes: (nodes: Node[]) => void;
};
We’ll now add a few more values – like the currently dragged element from the palette, whether the palette is expanded, and the selection state of nodes and edges.
// use-diagram-store.ts
export type DiagramState = {
nodes: Node[];
edges: Edge[];
reactFlowInstance: ReactFlowInstance | null;
selectedNodes: Node[];
setSelectedNodes: (nodes: Node[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
onInit: (instance: ReactFlowInstance) => void;
addNodes: (nodes: Node[]) => void;
// ------
isPaletteExpanded: boolean;
setIsPaletteExpanded: (isExpanded: boolean) => void;
draggedItemType: DraggedItem | null;
setDraggedItemType: (item: DraggedItem | null) => void;
selectedElements: (Node | Edge)[];
setSelectedElements: (elements: (Node | Edge)[]) => void;
addToSelection: (elements: (Node | Edge)[]) => void;
removeFromSelection: (elements: (Node | Edge)[] | string[]) => void;
hoveredElementId: string | null;
};
As the state grows, every new method or value makes it more complex. To avoid creating large, hard-to-maintain files, it’s worth breaking the state into smaller slices.
In Zustand, slices help you organize large and complex state into manageable sections. Each slice represents a distinct part of the application’s state, typically tied to a specific feature or domain.
Start by defining types for the set
and get
methods:
// use-diagram-store.ts
export type SetDiagramState = (
partial:
| DiagramState
| Partial<DiagramState>
| ((
state: DiagramState,
) => DiagramState | Partial<DiagramState>),
replace?: false | undefined,
) => void;
export type GetDiagramState = () => DiagramState;
A sample slice structure might look like this:
// create-workspace-slice.ts
export type WorkspaceState = {
nodes: Node[];
edges: Edge[];
reactFlowInstance: ReactFlowInstance | null;
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
onInit: (instance: ReactFlowInstance) => void;
addNodes: (nodes: Node[]) => void;
};
export const createWorkspaceSlice = (set: SetDiagramState, get: GetDiagramState): WorkspaceState => ({
nodes: [],
edges: [],
// ... rest of the slice
});
// create-palette-slice.ts
export type PaletteState = {
isPaletteExpanded: boolean;
setIsPaletteExpanded: (isExpanded: boolean) => void;
draggedItemType: DraggedItem | null;
setDraggedItemType: (item: DraggedItem | null) => void;
};
export const createPaletteSlice = (set: SetDiagramState, get: GetDiagramState): PaletteState => ({
isPaletteExpanded: true,
setIsPaletteExpanded: (isPaletteExpanded) => set({ isPaletteExpanded }),
draggedItemType: null,
setDraggedItemType: (draggedItemType) => set({ draggedItemType }),
});
// create-selection-slice.ts
export type SelectionState = {
selectedElements: (Node | Edge)[];
setSelectedElements: (elements: (Node | Edge)[]) => void;
addToSelection: (elements: (Node | Edge)[]) => void;
removeFromSelection: (elements: (Node | Edge)[] | string[]) => void;
hoveredElementId: string | null;
};
export const createSelectionSlice = (set: SetDiagramState, get: GetDiagramState): SelectionState => ({
selectedElements: [],
setSelectedElements: (selectedElements) => set({ selectedElements }),
// ... rest of the store
});
Back in to the use-diagram-store.ts
file:
// use-diagram-store.ts
export type DiagramState = WorkspaceState & PaletteState & SelectionState;
export const useDiagramStore = create<DiagramState>((set, get) => {
...createWorkspaceSlice(set, get),
...createPaletteSlice(set, get),
...createSelectionSlice(set, get),
});
Using a sliced store doesn’t change how it behaves – it simply improves code clarity and organization. You can still access all values and methods from the same hook:
const [selectedElements, isPaletteExpanded, reactFlowInstance] = useDiagramStore((state) => [
state.selectedElements,
state.isPaletteExpanded,
state.reactFlowInstance,
]);
For more on using slices in Zustand, check out: Slices Pattern.
Fill the form below to get an e-book where you'll find the full version of this guide with additional information on: