State management in React Flow: How to manage it wisely? [EBOOK]

Maciej Kaźmierczyk
Jun 30, 2025
2
min read

This article highlights best practices for state management, along with practical use cases for different scenarios.

Introduction

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.

When to use global state

  • When you need communication between components that aren’t directly nested in the component tree. Global state lets them share data and stay in sync even if they’re not structurally related.
  • When you want to enable interactions between nodes that affect the entire diagram. For instance, setting a node color that applies globally and updates across the app.
  • When you want to simplify components. With global state, components can just dispatch actions to update data, without passing event handlers down the component tree. This helps prevent tangled data flows.

Improve state management in React Flow. Learn best practices and practical solutions in our free ebook

Get your copy

Uncontrolled state

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.

Adding nodes

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>;
};

Getting nodes and edges

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.

Removing nodes and 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>;
};

Updating nodes and edges

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.

Controlled state

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.

Creating the store

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 });
  },
}));

Using the store

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.

Integrating the store with the ReactFlow component

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.

Adding nodes

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>;
};

Removing nodes and edges

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>;
};

Slicing the store into parts

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.

Read the full version

Fill the form below to get an e-book where you'll find the full version of this guide with additional information on:

  • Optimization
  • Initializing state with default values  
  • Saving and loading state  
  • Defining actions outside the state   
  • Selectors   
  • Memoized selectors
Contact details
By sending a message you allow Synergia Pro Sp. z o.o., with its registered office in Poland, Wroclaw (51-607) Czackiego Street 71, to process your personal data provided by you in the contact form for the purpose of contacting you and providing you with the information you requested. You can withdraw your consent at any time. For more information on data processing and the data controller please refer to our Privacy policy.
*Required
Thank you! Your submission has been received!
Thanks for checking out our e-book! Tap the button below to start reading.
Oops! Something went wrong while submitting the form.
Maciej Kaźmierczyk
Software developer

Front-end developer specializing in React and creating seamless user experiences. Computer science graduate currently completing machine learning studies, focused on building complex, intuitive interfaces and effective data visualizations.

Get more from me on:
Share:

Articles you might be interested in

Custom product configurator – why is it worth building one?

Discover why more companies are shifting away from generic tools to embrace custom product configurator software that perfectly aligns with their unique needs.

Content team
Jun 11, 2025

Real-time collaboration for multiple users in React Flow projects with Yjs [EBOOK]

Get to know how to add multi-user and real-time collaboration to diagrams build in React Flow using Yjs.

Tomasz Świstak
May 8, 2025

React Flow: Everything you need to know

In this guide, you'll learn all essential concepts about React Flow. You're new to the topic? This React Flow tutorial will get you started!

Content team
Apr 25, 2025