mcpField
R
react-flow-advanced

react-flow-advanced

github.com/existential-birds開発85631 回インストール11 週間前に更新
リポジトリ

react-flow-advanced について

インタラクティブなノードベースの画面を構築する開発者向けに、React Flowの高度な実装パターンをまとめたスキルです。動作やパフォーマンスを確認するためのチェック項目も含みます。

主な機能

  1. 01ネストしたサブフローとグループノード
  2. 02カスタム接続ライン
  3. 03外部からのドラッグ&ドロップ
  4. 04状態の取り消しとやり直し
  5. 05dagreによるプログラムレイアウト
  6. 06セレクターとノード変更ミドルウェア

活用シーン

  1. 01グループの親子ノード動作の検証
  2. 02外部ドラッグ&ドロップによるノード追加
  3. 03グラフ編集の取り消しとやり直しの実装
  4. 04dagreによるノードのプログラム配置

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/existential-birds/beagle --skill react-flow-advanced

インストール後、Claude Code で/react-flow-advancedを入力して読み込まれたか確認します。

詳細

高度な React Flow パターン

ゲート(リリース前に確認)

「動くと思う」ではなく、順番に確認してください。

  1. サブフロー/グループ: 合格条件: すべての parentId が既存のノード id と一致していること。親の type が nodeTypes に登録されていること。子の位置が意図したとおり親を基準としていること(グループの内側/外側でドラッグして1つ抜き取り確認します)。
  2. カスタム接続線: 合格条件: 有効/無効なドラッグで、線の太さまたは connectionStatus に目に見える違いがあること。無効な座標に対しても getSmoothStepPath によるパスがコンソールエラーなしで描画されること。
  3. 外部からのドラッグ&ドロップ: 合格条件: onDragOver が必ず preventDefault() を呼び出すこと。ドロップ位置には screenToFlowPosition を使うこと(clientX/clientY をそのままフロー座標として使用しないこと)。ペイン上で新しいノードがカーソルの下に表示されること。
  4. 元に戻す/やり直し: 合格条件: 1回元に戻すと直前の { nodes, edges } に戻ること。やり直すと復元されること。素早く変更しても、canUndo/canRedo と表示中のグラフに不整合が生じないこと(追加 → 元に戻す → 1回やり直す操作を確認します)。
  5. プログラムによるレイアウト(dagre): 合格条件: setNodes の後、ノードの位置が意図した rankdir と一致すること。レイアウト後に fitView が実行されること(例:requestAnimationFrame を使い、ビューポートが古い状態にならないようにします)。
  6. ドロップ時に新しいノードへ接続: 合格条件: 空のペインにドロップするとノードとソースハンドルからのエッジが作成されること。有効なターゲットにドロップしてもノードが重複しないこと(ノードを追加するのは無効なドロップの場合のみです)。
  7. セレクター/ストア: 合格条件: オブジェクトを使って useStore を呼び出すコンポーネントでは shallow(または同等の方法)を使い、無関係なストアの更新のたびに再レンダリングされないこと。

サブフロー(ネストされたノード)

const nodes = [
  // Parent (group) node
  {
    id: 'group-1',
    type: 'group',
    position: { x: 0, y: 0 },
    style: { width: 400, height: 300, padding: 10 },
    data: { label: 'Group' },
  },
  // Child nodes
  {
    id: 'child-1',
    parentId: 'group-1',        // Reference parent
    extent: 'parent',           // Constrain to parent bounds
    expandParent: true,         // Auto-expand parent if dragged to edge
    position: { x: 20, y: 50 }, // Relative to parent
    data: { label: 'Child 1' },
  },
  {
    id: 'child-2',
    parentId: 'group-1',
    extent: 'parent',
    position: { x: 200, y: 50 },
    data: { label: 'Child 2' },
  },
];
グループノードコンポーネント
function GroupNode({ data, id }: NodeProps) {
  return (
    <div className="group-node">
      <div className="group-header">{data.label}</div>
      {/* Children are rendered automatically by React Flow */}
    </div>
  );
}

カスタム接続線

import { ConnectionLineComponentProps, getSmoothStepPath } from '@xyflow/react';

function CustomConnectionLine({
  fromX, fromY, fromPosition,
  toX, toY, toPosition,
  connectionStatus,
}: ConnectionLineComponentProps) {
  const [path] = getSmoothStepPath({
    sourceX: fromX,
    sourceY: fromY,
    sourcePosition: fromPosition,
    targetX: toX,
    targetY: toY,
    targetPosition: toPosition,
  });

  return (
    <g>
      <path
        d={path}
        fill="none"
        stroke={connectionStatus === 'valid' ? '#22c55e' : '#ef4444'}
        strokeWidth={2}
        strokeDasharray="5 5"
      />
    </g>
  );
}

<ReactFlow connectionLineComponent={CustomConnectionLine} />

外部ソースからのドラッグ&ドロップ

import { useCallback, useRef, useState } from 'react';
import { useReactFlow } from '@xyflow/react';

function DnDFlow() {
  const reactFlowWrapper = useRef(null);
  const { screenToFlowPosition, addNodes } = useReactFlow();
  const [reactFlowInstance, setReactFlowInstance] = useState(null);

  const onDragOver = useCallback((event: DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
  }, []);

  const onDrop = useCallback((event: DragEvent) => {
    event.preventDefault();

    const type = event.dataTransfer.getData('application/reactflow');
    if (!type) return;

    // Convert screen position to flow position
    const position = screenToFlowPosition({
      x: event.clientX,
      y: event.clientY,
    });

    const newNode = {
      id: `${Date.now()}`,
      type,
      position,
      data: { label: `${type} node` },
    };

    addNodes(newNode);
  }, [screenToFlowPosition, addNodes]);

  return (
    <div ref={reactFlowWrapper} style={{ height: '100%' }}>
      <ReactFlow
        onDragOver={onDragOver}
        onDrop={onDrop}
        onInit={setReactFlowInstance}
      />
    </div>
  );
}

// Sidebar component
function Sidebar() {
  const onDragStart = (event: DragEvent, nodeType: string) => {
    event.dataTransfer.setData('application/reactflow', nodeType);
    event.dataTransfer.effectAllowed = 'move';
  };

  return (
    <aside>
      <div draggable onDragStart={(e) => onDragStart(e, 'input')}>
        Input Node
      </div>
      <div draggable onDragStart={(e) => onDragStart(e, 'default')}>
        Default Node
      </div>
    </aside>
  );
}

元に戻す/やり直し

import { useCallback, useState } from 'react';

function useUndoRedo<T>(initialState: T) {
  const [history, setHistory] = useState<T[]>([initialState]);
  const [index, setIndex] = useState(0);

  const state = history[index];

  const setState = useCallback((newState: T | ((prev: T) => T)) => {
    setHistory((prev) => {
      const resolved = typeof newState === 'function'
        ? (newState as (prev: T) => T)(prev[index])
        : newState;

      // Remove future states and add new state
      const newHistory = prev.slice(0, index + 1);
      return [...newHistory, resolved];
    });
    setIndex((i) => i + 1);
  }, [index]);

  const undo = useCallback(() => {
    setIndex((i) => Math.max(0, i - 1));
  }, []);

  const redo = useCallback(() => {
    setIndex((i) => Math.min(history.length - 1, i + 1));
  }, [history.length]);

  const canUndo = index > 0;
  const canRedo = index < history.length - 1;

  return { state, setState, undo, redo, canUndo, canRedo };
}

// Usage
function Flow() {
  const {
    state: { nodes, edges },
    setState,
    undo, redo, canUndo, canRedo
  } = useUndoRedo({ nodes: initialNodes, edges: initialEdges });

  // Capture state on significant changes
  const onNodesChange = useCallback((changes) => {
    const hasPositionChange = changes.some(c => c.type === 'position' && !c.dragging);
    if (hasPositionChange) {
      setState(prev => ({
        nodes: applyNodeChanges(changes, prev.nodes),
        edges: prev.edges,
      }));
    }
  }, [setState]);
}

dagre によるプログラムレイアウト

import dagre from 'dagre';

interface LayoutOptions {
  direction: 'TB' | 'BT' | 'LR' | 'RL';
  nodeWidth: number;
  nodeHeight: number;
}

function getLayoutedElements(
  nodes: Node[],
  edges: Edge[],
  options: LayoutOptions = { direction: 'TB', nodeWidth: 172, nodeHeight: 36 }
) {
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: options.direction });
  g.setDefaultEdgeLabel(() => ({}));

  nodes.forEach((node) => {
    g.setNode(node.id, {
      width: node.measured?.width ?? options.nodeWidth,
      height: node.measured?.height ?? options.nodeHeight,
    });
  });

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  const layoutedNodes = nodes.map((node) => {
    const nodeWithPosition = g.node(node.id);
    return {
      ...node,
      position: {
        x: nodeWithPosition.x - (node.measured?.width ?? options.nodeWidth) / 2,
        y: nodeWithPosition.y - (node.measured?.height ?? options.nodeHeight) / 2,
      },
    };
  });

  return { nodes: layoutedNodes, edges };
}

// Usage after nodes are measured
function Flow() {
  const { fitView } = useReactFlow();

  const onLayout = useCallback((direction: 'TB' | 'LR') => {
    const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
      nodes,
      edges,
      { direction, nodeWidth: 150, nodeHeight: 50 }
    );

    setNodes([...layoutedNodes]);
    setEdges([...layoutedEdges]);

    window.requestAnimationFrame(() => {
      fitView({ duration: 500 });
    });
  }, [nodes, edges, setNodes, setEdges, fitView]);
}

ドロップ時のエッジ接続

function Flow() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const { screenToFlowPosition } = useReactFlow();

  const onConnectEnd = useCallback(
    (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => {
      // Only proceed if dropped on pane (not on a node)
      if (!connectionState.isValid && connectionState.fromHandle) {
        const id = `${Date.now()}`;
        const { clientX, clientY } = 'changedTouches' in event
          ? event.changedTouches[0]
          : event;

        const newNode = {
          id,
          position: screenToFlowPosition({ x: clientX, y: clientY }),
          data: { label: 'New Node' },
        };

        setNodes((nds) => [...nds, newNode]);
        setEdges((eds) => [
          ...eds,
          {
            id: `e-${connectionState.fromNode?.id}-${id}`,
            source: connectionState.fromNode?.id ?? '',
            target: id,
          },
        ]);
      }
    },
    [screenToFlowPosition, setNodes, setEdges]
  );

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnectEnd={onConnectEnd}
    />
  );
}

エッジからノードデータにアクセスする

import { useNodesData, type EdgeProps } from '@xyflow/react';

function DataEdge({ source, target, ...props }: EdgeProps) {
  // Get data for source and target nodes
  const nodesData = useNodesData([source, target]);
  const sourceData = nodesData[0];
  const targetData = nodesData[1];

  const [path, labelX, labelY] = getSmoothStepPath(props);

  return (
    <>
      <BaseEdge path={path} />
      <EdgeLabelRenderer>
        <div style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}>
          {sourceData?.data?.label} → {targetData?.data?.label}
        </div>
      </EdgeLabelRenderer>
    </>
  );
}

ノード変更のためのミドルウェア

// Filter or modify changes before they're applied
const onNodesChangeMiddleware = useCallback((changes: NodeChange[]) => {
  // Example: Prevent deletion of certain nodes
  const filteredChanges = changes.filter((change) => {
    if (change.type === 'remove') {
      const node = nodes.find((n) => n.id === change.id);
      return node?.data?.deletable !== false;
    }
    return true;
  });

  setNodes((nds) => applyNodeChanges(filteredChanges, nds));
}, [nodes, setNodes]);

キーボードショートカット

import { useKeyPress } from '@xyflow/react';

function Flow() {
  const { deleteElements, getNodes, getEdges, fitView } = useReactFlow();

  // Ctrl/Cmd + A: Select all
  const selectAllPressed = useKeyPress(['Meta+a', 'Control+a']);

  useEffect(() => {
    if (selectAllPressed) {
      setNodes((nds) => nds.map((n) => ({ ...n, selected: true })));
      setEdges((eds) => eds.map((e) => ({ ...e, selected: true })));
    }
  }, [selectAllPressed]);

  // Custom delete handler
  const deletePressed = useKeyPress(['Backspace', 'Delete']);

  useEffect(() => {
    if (deletePressed) {
      const selectedNodes = getNodes().filter((n) => n.selected);
      const selectedEdges = getEdges().filter((e) => e.selected);
      deleteElements({ nodes: selectedNodes, edges: selectedEdges });
    }
  }, [deletePressed]);
}

パフォーマンス:セレクターのメモ化

import { useCallback } from 'react';
import { useStore, type ReactFlowState } from '@xyflow/react';
import { shallow } from 'zustand/shallow';

// Create stable selector outside component
const nodesSelector = (state: ReactFlowState) => state.nodes;

// Or use multiple values with shallow compare
const flowStateSelector = (state: ReactFlowState) => ({
  nodes: state.nodes,
  edges: state.edges,
  viewport: state.transform,
});

function FlowInfo() {
  const { nodes, edges, viewport } = useStore(flowStateSelector, shallow);
  return <div>Nodes: {nodes.length}, Edges: {edges.length}</div>;
}

コメント