diff --git a/.changeset/selfish-nails-sing.md b/.changeset/selfish-nails-sing.md
new file mode 100644
index 00000000..9cbc5b81
--- /dev/null
+++ b/.changeset/selfish-nails-sing.md
@@ -0,0 +1,5 @@
+---
+'@xyflow/react': minor
+---
+
+Add `experimental_useOnNodesChangeMiddleware` hook
diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts
index 886eecbd..4cf4b57b 100644
--- a/examples/react/src/App/routes.ts
+++ b/examples/react/src/App/routes.ts
@@ -60,6 +60,7 @@ import DevTools from '../examples/DevTools';
import Redux from '../examples/Redux';
import MovingHandles from '../examples/MovingHandles';
import DetachedHandle from '../examples/DetachedHandle';
+import Middlewares from '../examples/Middlewares';
export interface IRoute {
name: string;
@@ -238,6 +239,11 @@ const routes: IRoute[] = [
path: 'layouting',
component: Layouting,
},
+ {
+ name: 'Middlewares',
+ path: 'middlewares',
+ component: Middlewares,
+ },
{
name: 'Multi setNodes',
path: 'multi-setnodes',
diff --git a/examples/react/src/examples/Middlewares/RestrictExtent.tsx b/examples/react/src/examples/Middlewares/RestrictExtent.tsx
new file mode 100644
index 00000000..7a447414
--- /dev/null
+++ b/examples/react/src/examples/Middlewares/RestrictExtent.tsx
@@ -0,0 +1,54 @@
+import { NodeChange, experimental_useOnNodesChangeMiddleware } from '@xyflow/react';
+import { useCallback, useState } from 'react';
+
+export function RestrictExtent({
+ label = 'Restrict Extent',
+ minX = -Infinity,
+ minY = -Infinity,
+ maxX = Infinity,
+ maxY = Infinity,
+}: {
+ label?: string;
+ minX?: number;
+ minY?: number;
+ maxX?: number;
+ maxY?: number;
+}) {
+ const [isEnabled, setIsEnabled] = useState(false);
+ experimental_useOnNodesChangeMiddleware(
+ useCallback(
+ (changes: NodeChange[]) => {
+ if (!isEnabled) return changes;
+ return changes.map((change) => {
+ const { type } = change;
+ if (type === 'position') {
+ const { position } = change;
+ if (position) {
+ position.x = Math.min(Math.max(position.x, minX), maxX);
+ position.y = Math.min(Math.max(position.y, minY), maxY);
+ change.position = position;
+ }
+ } else if (type === 'add' || type === 'replace') {
+ const { item } = change;
+ if (item) {
+ item.position.x = Math.min(Math.max(item.position.x, minX), maxX);
+ item.position.y = Math.min(Math.max(item.position.y, minY), maxY);
+ change.item = item;
+ }
+ }
+ return change;
+ });
+ },
+ [minX, minY, maxX, maxY, isEnabled]
+ )
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/examples/react/src/examples/Middlewares/index.tsx b/examples/react/src/examples/Middlewares/index.tsx
new file mode 100644
index 00000000..c7a05770
--- /dev/null
+++ b/examples/react/src/examples/Middlewares/index.tsx
@@ -0,0 +1,85 @@
+import { useCallback } from 'react';
+import {
+ ReactFlow,
+ MiniMap,
+ Background,
+ BackgroundVariant,
+ Controls,
+ ReactFlowProvider,
+ Edge,
+ useReactFlow,
+ Panel,
+ useNodesState,
+ useEdgesState,
+ addEdge,
+ Connection,
+} from '@xyflow/react';
+import { initialNodes, initialEdges } from '../CancelConnection/data';
+import { RestrictExtent } from './RestrictExtent';
+
+const a = { id: 'a', data: { label: 'A' }, position: { x: 250, y: 5 } };
+const b = { id: 'b', data: { label: 'B' }, position: { x: 100, y: 100 } };
+const c = { id: 'c', data: { label: 'C' }, position: { x: 400, y: 100 } };
+
+const SetNotesBatchingFlow = () => {
+ const { setNodes, updateNode } = useReactFlow();
+
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
+ const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
+
+ const triggerMultipleSetNodes = useCallback(() => {
+ setNodes([a]);
+ setNodes((nodes) => [...nodes, b]);
+ setNodes((nodes) => [...nodes, c]);
+ setNodes((nodes) =>
+ nodes.map((node) =>
+ node.id === 'a' ? { ...node, position: { x: node.position.x + 2000, y: node.position.y + 20 } } : node
+ )
+ );
+ }, []);
+
+ const triggerMultipleUpdateNodes = useCallback(() => {
+ triggerMultipleSetNodes();
+ updateNode('a', (a) => ({ position: { x: a.position.x + 20, y: a.position.y + 20 } }));
+ updateNode('b', (b) => ({ position: { x: b.position.x + 20, y: b.position.y + 20 } }));
+ updateNode('c', (c) => ({ position: { x: c.position.x + 20, y: c.position.y + 20 } }));
+ updateNode('a', (a) => ({ data: { ...a.data, label: `A ${Date.now()}` } }));
+ updateNode('b', (b) => ({ data: { ...b.data, label: `B ${Date.now()}` } }));
+ updateNode('c', (c) => ({ data: { ...c.data, label: `C ${Date.now()}` } }));
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx
index b2ed270f..f6c6436c 100644
--- a/packages/react/src/components/BatchProvider/index.tsx
+++ b/packages/react/src/components/BatchProvider/index.tsx
@@ -28,7 +28,15 @@ export function BatchProvider();
const nodeQueueHandler = useCallback((queueItems: QueueItem[]) => {
- const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup, fitViewQueued } = store.getState();
+ const {
+ nodes = [],
+ setNodes,
+ hasDefaultNodes,
+ onNodesChange,
+ nodeLookup,
+ fitViewQueued,
+ onNodesChangeMiddlewareMap,
+ } = store.getState();
/*
* This is essentially an `Array.reduce` in imperative clothing. Processing
@@ -40,11 +48,15 @@ export function BatchProvider[];
+ for (const middleware of onNodesChangeMiddlewareMap.values()) {
+ changes = middleware(changes);
+ }
+
if (hasDefaultNodes) {
setNodes(next);
}
diff --git a/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts
new file mode 100644
index 00000000..d79d6773
--- /dev/null
+++ b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+import type { EdgeChange } from '@xyflow/system';
+
+import { useStoreApi } from './useStore';
+import type { Edge, Node } from '../types';
+
+/**
+ * Registers a middleware function to transform edge changes.
+ *
+ * @public
+ * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration.
+ */
+export function experimental_useOnEdgesChangeMiddleware(
+ fn: (changes: EdgeChange[]) => EdgeChange[]
+) {
+ const store = useStoreApi();
+ const [symbol] = useState(() => Symbol());
+
+ useEffect(() => {
+ const { onEdgesChangeMiddlewareMap } = store.getState();
+ onEdgesChangeMiddlewareMap.set(symbol, fn);
+ }, [fn]);
+
+ useEffect(() => {
+ const { onEdgesChangeMiddlewareMap } = store.getState();
+ return () => {
+ onEdgesChangeMiddlewareMap.delete(symbol);
+ };
+ }, []);
+}
diff --git a/packages/react/src/hooks/useOnNodesChangeMiddleware.ts b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts
new file mode 100644
index 00000000..fe7ea6f6
--- /dev/null
+++ b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+import type { NodeChange } from '@xyflow/system';
+
+import { useStoreApi } from './useStore';
+import type { Edge, Node } from '../types';
+
+/**
+ * Registers a middleware function to transform node changes.
+ *
+ * @public
+ * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration.
+ */
+export function experimental_useOnNodesChangeMiddleware(
+ fn: (changes: NodeChange[]) => NodeChange[]
+) {
+ const store = useStoreApi();
+ const [symbol] = useState(() => Symbol());
+
+ useEffect(() => {
+ const { onNodesChangeMiddlewareMap } = store.getState();
+ onNodesChangeMiddlewareMap.set(symbol, fn);
+ }, [fn]);
+
+ useEffect(() => {
+ const { onNodesChangeMiddlewareMap } = store.getState();
+ return () => {
+ onNodesChangeMiddlewareMap.delete(symbol);
+ };
+ }, []);
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index b4a67f2e..f251ba5d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -30,6 +30,9 @@ export { useConnection } from './hooks/useConnection';
export { useInternalNode } from './hooks/useInternalNode';
export { useNodeId } from './contexts/NodeIdContext';
+export { experimental_useOnNodesChangeMiddleware } from './hooks/useOnNodesChangeMiddleware';
+export { experimental_useOnEdgesChangeMiddleware } from './hooks/useOnEdgesChangeMiddleware';
+
export { applyNodeChanges, applyEdgeChanges } from './utils/changes';
export { isNode, isEdge } from './utils/general';
diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts
index d6ff3b60..fa1b5e3d 100644
--- a/packages/react/src/store/index.ts
+++ b/packages/react/src/store/index.ts
@@ -177,8 +177,8 @@ const createStore = ({
},
updateNodePositions: (nodeDragItems, dragging = false) => {
const parentExpandChildren: ParentExpandChild[] = [];
- const changes = [];
- const { nodeLookup, triggerNodeChanges, connection, updateConnection } = get();
+ let changes = [];
+ const { nodeLookup, triggerNodeChanges, connection, updateConnection, onNodesChangeMiddlewareMap } = get();
for (const [id, dragItem] of nodeDragItems) {
// we are using the nodelookup to be sure to use the current expandParent and parentId value
@@ -223,6 +223,10 @@ const createStore = ({
changes.push(...parentExpandChanges);
}
+ for (const middleware of onNodesChangeMiddlewareMap.values()) {
+ changes = middleware(changes);
+ }
+
triggerNodeChanges(changes);
},
triggerNodeChanges: (changes) => {
diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts
index 3e561ce9..c7ddda17 100644
--- a/packages/react/src/store/initialState.ts
+++ b/packages/react/src/store/initialState.ts
@@ -146,6 +146,9 @@ const getInitialState = ({
lib: 'react',
debug: false,
ariaLabelConfig: defaultAriaLabelConfig,
+
+ onNodesChangeMiddlewareMap: new Map(),
+ onEdgesChangeMiddlewareMap: new Map(),
};
};
diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts
index 59a05127..717961ec 100644
--- a/packages/react/src/types/store.ts
+++ b/packages/react/src/types/store.ts
@@ -152,6 +152,9 @@ export type ReactFlowStore[]) => NodeChange[]>;
+ onEdgesChangeMiddlewareMap: Map[]) => EdgeChange[]>;
};
export type ReactFlowActions = {