diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts
index 2665abfa..18307c79 100644
--- a/examples/react/src/App/routes.ts
+++ b/examples/react/src/App/routes.ts
@@ -45,6 +45,7 @@ import InteractiveMinimap from '../examples/InteractiveMinimap';
import UseOnSelectionChange from '../examples/UseOnSelectionChange';
import NodeToolbar from '../examples/NodeToolbar';
import useNodesInitialized from '../examples/UseNodesInit';
+import useHandleConnectionStatus from '../examples/UseHandleConnectionStatus';
export interface IRoute {
name: string;
@@ -273,6 +274,11 @@ const routes: IRoute[] = [
path: 'usereactflow',
component: UseReactFlow,
},
+ {
+ name: 'useHandleConnectionStatus',
+ path: 'usehandleconnectionstatus',
+ component: useHandleConnectionStatus,
+ },
{
name: 'useUpdateNodeInternals',
path: 'useupdatenodeinternals',
diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx
index aebc8e0e..6857e1ac 100644
--- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx
+++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx
@@ -1,4 +1,4 @@
-import React, { memo, FC, CSSProperties, useCallback } from 'react';
+import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react';
import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react';
const targetHandleStyle: CSSProperties = { background: '#555' };
diff --git a/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx
new file mode 100644
index 00000000..f8a31143
--- /dev/null
+++ b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx
@@ -0,0 +1,40 @@
+import { memo, FC, useEffect, useCallback } from 'react';
+import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react';
+import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle';
+
+function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) {
+ const onConnect = useCallback(
+ (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections),
+ [nodeId]
+ );
+
+ const onDisconnect = useCallback(
+ (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections),
+ [nodeId]
+ );
+ const status = useHandleConnectionStatus({
+ handleType: handleProps.type,
+ handleId: handleProps.id,
+ onConnect,
+ onDisconnect,
+ });
+
+ useEffect(() => {
+ console.log('useEffect, node id:', nodeId, handleProps.type, status);
+ }, [status]);
+
+ return ;
+}
+
+const CustomNode: FC = ({ id }) => {
+ return (
+
+ );
+};
+
+export default memo(CustomNode);
diff --git a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx
new file mode 100644
index 00000000..85d4f9e1
--- /dev/null
+++ b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx
@@ -0,0 +1,42 @@
+import { memo, FC, useEffect, useCallback } from 'react';
+import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react';
+import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle';
+
+function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) {
+ const onConnect = useCallback(
+ (connections: Connection[]) => {
+ console.log('onConnect handler, node id:', nodeId, connections);
+ },
+ [nodeId]
+ );
+ const onDisconnect = useCallback(
+ (connections: Connection[]) => {
+ console.log('onDisconnect handler, node id:', nodeId, connections);
+ },
+ [nodeId]
+ );
+ const status = useHandleConnectionStatus({
+ handleType: handleProps.type,
+ handleId: handleProps.id,
+ onConnect,
+ onDisconnect,
+ });
+
+ useEffect(() => {
+ // console.log('useEffect, node id:', nodeId, handleProps.type, status);
+ }, [status]);
+
+ return ;
+}
+
+const CustomNode: FC = ({ id }) => {
+ return (
+
+ );
+};
+
+export default memo(CustomNode);
diff --git a/examples/react/src/examples/UseHandleConnectionStatus/index.tsx b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx
new file mode 100644
index 00000000..c6aa4a68
--- /dev/null
+++ b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx
@@ -0,0 +1,118 @@
+import { useCallback } from 'react';
+import {
+ ReactFlow,
+ MiniMap,
+ Controls,
+ addEdge,
+ Connection,
+ useNodesState,
+ useEdgesState,
+ Background,
+} from '@xyflow/react';
+
+import MultiHandleNode from './MultiHandleNode';
+import SingleHandleNode from './SingleHandleNode';
+
+const nodeTypes = {
+ multi: MultiHandleNode,
+ single: SingleHandleNode,
+};
+
+const initNodes = [
+ {
+ id: '1',
+ type: 'single',
+ data: {},
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: '2',
+ type: 'single',
+ data: {},
+ position: { x: 200, y: -100 },
+ },
+ {
+ id: '3',
+ type: 'single',
+ data: {},
+ position: { x: 200, y: 100 },
+ },
+
+ {
+ id: '4',
+ type: 'multi',
+ data: {},
+ position: { x: 400, y: 0 },
+ },
+ {
+ id: '5',
+ type: 'multi',
+ data: {},
+ position: { x: 600, y: -100 },
+ },
+ {
+ id: '6',
+ type: 'multi',
+ data: {},
+ position: { x: 600, y: 100 },
+ },
+];
+
+const initEdges = [
+ {
+ id: 'e1-2',
+ source: '1',
+ target: '2',
+ },
+ {
+ id: 'e1-3',
+ source: '1',
+ target: '3',
+ },
+
+ {
+ id: 'e4a-5',
+ source: '4',
+ sourceHandle: 'a',
+ target: '5',
+ },
+ {
+ id: 'e4b-5',
+ source: '4',
+ sourceHandle: 'b',
+ target: '6',
+ },
+];
+
+const defaultEdgeOptions = {
+ animated: true,
+};
+
+const CustomNodeFlow = () => {
+ const [nodes, setNodes, onNodesChange] = useNodesState(initNodes);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges);
+
+ const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default CustomNodeFlow;
diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnectionStatus.ts
new file mode 100644
index 00000000..062d5d93
--- /dev/null
+++ b/packages/react/src/hooks/useHandleConnectionStatus.ts
@@ -0,0 +1,85 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { Connection, HandleType } from '@xyflow/system';
+
+import { useStore } from './useStore';
+import { useNodeId } from '../contexts/NodeIdContext';
+
+type useHandleConnectionStatusParams = {
+ handleType: HandleType;
+ nodeId?: string;
+ handleId?: string | null;
+ onConnect?: (connections: Connection[]) => void;
+ onDisconnect?: (connections: Connection[]) => void;
+};
+
+function connectionsEqual(a: Connection[] | null, b: Connection[] | null) {
+ if (!a && !b) {
+ return true;
+ }
+
+ if (!a || !b) {
+ return false;
+ }
+
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ return a.every((connA) =>
+ b.find(
+ (connB) =>
+ connA.source === connB.source &&
+ connA.target === connB.target &&
+ connA.sourceHandle === connB.sourceHandle &&
+ connA.targetHandle === connB.targetHandle
+ )
+ );
+}
+
+export function useHandleConnectionStatus({
+ handleType,
+ nodeId,
+ handleId = null,
+ onConnect,
+ onDisconnect,
+}: useHandleConnectionStatusParams): {
+ connected: boolean;
+ connections: Connection[] | null;
+} {
+ const _nodeId = useNodeId();
+ const prevConnections = useRef(null);
+ const currentNodeId = nodeId || _nodeId;
+
+ const connections = useStore(
+ (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`) || null,
+ connectionsEqual
+ );
+
+ useEffect(() => {
+ // we don't want to trigger the handlers for the initial render
+ if (prevConnections.current && prevConnections.current !== connections) {
+ if (prevConnections.current?.length > (connections?.length ?? 0)) {
+ const disconnect = prevConnections.current.filter(
+ (prevConnection) => !connections?.find((connection) => connection.source === prevConnection.source)
+ );
+ onDisconnect?.(disconnect);
+ } else if (connections?.length) {
+ const connect = connections.filter(
+ (connection) =>
+ !prevConnections.current?.find((prevConnection) => prevConnection.source === connection.source)
+ );
+ onConnect?.(connect);
+ }
+ }
+
+ prevConnections.current = connections ?? [];
+ }, [connections, onConnect, onDisconnect]);
+
+ return useMemo(
+ () => ({
+ connected: !!connections,
+ connections,
+ }),
+ [connections]
+ );
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 2a693638..d9013b7c 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -22,6 +22,7 @@ export { useStore, useStoreApi } from './hooks/useStore';
export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange';
export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange';
export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized';
+export { useHandleConnectionStatus } from './hooks/useHandleConnectionStatus';
export { useNodeId } from './contexts/NodeIdContext';
export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes';
diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts
index 0f50f385..0237491a 100644
--- a/packages/react/src/store/index.ts
+++ b/packages/react/src/store/index.ts
@@ -10,7 +10,7 @@ import {
} from '@xyflow/system';
import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
-import { updateNodesAndEdgesSelections } from './utils';
+import { updateConnectionLookup, updateNodesAndEdgesSelections } from './utils';
import getInitialState from './initialState';
import type {
ReactFlowState,
@@ -49,8 +49,12 @@ const createRFStore = ({
set({ nodes: nextNodes });
},
setEdges: (edges: Edge[]) => {
- const { defaultEdgeOptions = {} } = get();
- set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
+ const { defaultEdgeOptions = {}, connectionLookup } = get();
+ const nextEdges = edges.map((e) => ({ ...defaultEdgeOptions, ...e }));
+
+ updateConnectionLookup(connectionLookup, nextEdges);
+
+ set({ edges: nextEdges });
},
// when the user works with an uncontrolled flow,
// we set a flag `hasDefaultNodes` / `hasDefaultEdges`
diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts
index 48e5434f..1c843fe4 100644
--- a/packages/react/src/store/initialState.ts
+++ b/packages/react/src/store/initialState.ts
@@ -5,9 +5,11 @@ import {
getNodesBounds,
getViewportForBounds,
Transform,
+ Connection,
} from '@xyflow/system';
import type { Edge, Node, ReactFlowStore } from '../types';
+import { updateConnectionLookup } from './utils';
const getInitialState = ({
nodes = [],
@@ -23,6 +25,7 @@ const getInitialState = ({
fitView?: boolean;
} = {}): ReactFlowStore => {
const nodeLookup = new Map();
+ const connectionLookup = updateConnectionLookup(new Map(), edges);
const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false });
let transform: Transform = [0, 0, 1];
@@ -42,6 +45,7 @@ const getInitialState = ({
nodes: nextNodes,
nodeLookup,
edges: edges,
+ connectionLookup,
onNodesChange: null,
onEdgesChange: null,
hasDefaultNodes: false,
diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts
index 86cddde7..ac9e43fa 100644
--- a/packages/react/src/store/utils.ts
+++ b/packages/react/src/store/utils.ts
@@ -1,5 +1,6 @@
import type { StoreApi } from 'zustand';
import type { Edge, EdgeSelectionChange, Node, NodeSelectionChange, ReactFlowState } from '../types';
+import { Connection } from '@xyflow/system';
export function handleControlledSelectionChange(
changes: NodeSelectionChange[] | EdgeSelectionChange[],
@@ -42,3 +43,26 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get,
onEdgesChange?.(changedEdges);
}
}
+
+export function updateConnectionLookup(lookup: Map, edges: Edge[]) {
+ lookup.clear();
+
+ edges.forEach((edge) => {
+ const { source, target, sourceHandle = null, targetHandle = null } = edge;
+
+ if (source && target) {
+ const sourceKey = `${source}-source-${sourceHandle}`;
+ const targetKey = `${target}-target-${targetHandle}`;
+
+ const prevSource = lookup.get(sourceKey);
+ const prevTarget = lookup.get(targetKey);
+
+ const connection = { source, target, sourceHandle, targetHandle };
+
+ lookup.set(sourceKey, prevSource ? [...prevSource, connection] : [connection]);
+ lookup.set(targetKey, prevTarget ? [...prevTarget, connection] : [connection]);
+ }
+ });
+
+ return lookup;
+}
diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts
index c473cbd5..3ee1175d 100644
--- a/packages/react/src/types/store.ts
+++ b/packages/react/src/types/store.ts
@@ -24,6 +24,7 @@ import {
type OnMoveEnd,
type IsValidConnection,
type UpdateConnection,
+ Connection,
} from '@xyflow/system';
import type {
@@ -49,6 +50,7 @@ export type ReactFlowStore = {
nodes: Node[];
nodeLookup: Map;
edges: Edge[];
+ connectionLookup: Map;
onNodesChange: OnNodesChange | null;
onEdgesChange: OnEdgesChange | null;
hasDefaultNodes: boolean;