├── src ├── vite-env.d.ts ├── App │ ├── types.ts │ ├── MindMapNode │ │ ├── DragIcon.tsx │ │ └── index.tsx │ ├── MindMapEdge │ │ └── index.tsx │ ├── store.ts │ └── index.tsx ├── main.tsx └── index.css ├── vite.config.ts ├── tsconfig.node.json ├── index.html ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── public └── vite.svg /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/App/types.ts: -------------------------------------------------------------------------------- 1 | import { type Node } from '@xyflow/react'; 2 | 3 | export type NodeData = { 4 | label: string; 5 | }; 6 | 7 | export type MindMapNode = Node; 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Flow Mindmap App 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/App/MindMapNode/DragIcon.tsx: -------------------------------------------------------------------------------- 1 | // icon taken from grommet https://icons.grommet.io 2 | 3 | function DragIcon() { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | 16 | export default DragIcon; 17 | -------------------------------------------------------------------------------- /src/App/MindMapEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; 2 | 3 | function MindMapEdge(props: EdgeProps) { 4 | const { sourceX, sourceY, targetX, targetY } = props; 5 | 6 | const [edgePath] = getStraightPath({ 7 | sourceX, 8 | sourceY: sourceY + 18, 9 | targetX, 10 | targetY, 11 | }); 12 | 13 | return ; 14 | } 15 | 16 | export default MindMapEdge; 17 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { ReactFlowProvider } from '@xyflow/react'; 4 | 5 | import App from './App'; 6 | 7 | // all styles for this example app are in the index.css file to keep it as simple as possible 8 | import './index.css'; 9 | 10 | // we need to wrap our app in the ReactFlowProvider to be able to use the React Flow hooks in our App component 11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flow-mindmap-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@xyflow/react": "^12.4.4", 13 | "d3-force": "^3.0.0", 14 | "d3-hierarchy": "^3.1.2", 15 | "d3-quadtree": "^3.0.1", 16 | "nanoid": "^4.0.0", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "zustand": "^4.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.26", 23 | "@types/react-dom": "^18.0.9", 24 | "@vitejs/plugin-react": "^3.0.0", 25 | "typescript": "^4.9.3", 26 | "vite": "^4.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Flow Mind Map App 2 | 3 |  4 | 5 | This mind map app was created as an example for the blog post ["Tutorial: Build a Mind Map App with React Flow"](https://reactflow.dev/blog/mind-map-app-with-react-flow/), which has a step-by-step guide on how to build a this mindmap (for intermediate or advanced React users). [React Flow](https://reactflow.dev) is a library for creating node-based UIs. 6 | 7 | ### Features of the mindmap include 8 | - Quickly create new nodes on drag + mouse-release 9 | - Organize nodes by moving child notes with their parent 10 | - Edit text in nodes 11 | 12 | ### The tutorial covers React Flow topics including 13 | - Using Zustand for state management 14 | - Custom node with an input field 15 | - Using node area as a handle 16 | - Dynamic width and auto focus 17 | 18 | Demo: https://react-flow-mindmap.netlify.app 19 | 20 | ## Development 21 | 22 | We are using [Vite](https://vitejs.dev/) for the development. 23 | 24 | ### Installation 25 | 26 | Before you start, you need to install the dependencies: 27 | 28 | ```sh 29 | npm install 30 | ``` 31 | 32 | ### Dev Server 33 | 34 | ```sh 35 | npm run dev 36 | ``` 37 | 38 | ### Build 39 | 40 | ```sh 41 | npm run build 42 | ``` 43 | -------------------------------------------------------------------------------- /src/App/MindMapNode/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useEffect, useRef } from 'react'; 2 | import { Handle, NodeProps, Position } from '@xyflow/react'; 3 | 4 | import useStore from '../store'; 5 | import DragIcon from './DragIcon'; 6 | import { type MindMapNode } from '../types'; 7 | 8 | function MindMapNode({ id, data }: NodeProps) { 9 | const inputRef = useRef(null); 10 | const updateNodeLabel = useStore((state) => state.updateNodeLabel); 11 | 12 | useEffect(() => { 13 | setTimeout(() => { 14 | inputRef.current?.focus({ preventScroll: true }); 15 | }, 1); 16 | }, []); 17 | 18 | useLayoutEffect(() => { 19 | if (inputRef.current) { 20 | inputRef.current.style.width = `${data.label.length * 8}px`; 21 | } 22 | }, [data.label.length]); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | updateNodeLabel(id, evt.target.value)} 33 | className="input nodrag" 34 | ref={inputRef} 35 | /> 36 | 37 | 38 | 39 | 40 | > 41 | ); 42 | } 43 | 44 | export default MindMapNode; 45 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: #f8f8f8; 4 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 5 | font-size: 16px; 6 | line-height: 24px; 7 | font-weight: 400; 8 | } 9 | 10 | html, 11 | body, 12 | #root { 13 | height: 100%; 14 | } 15 | 16 | .header { 17 | color: #cdcdcd; 18 | } 19 | 20 | .react-flow__node-mindmap { 21 | background: white; 22 | border-radius: 2px; 23 | border: none; 24 | padding: 6px 10px; 25 | font-weight: 700; 26 | } 27 | 28 | .react-flow__handle.target { 29 | top: 50%; 30 | pointer-events: none; 31 | opacity: 0; 32 | } 33 | 34 | .react-flow__handle.source { 35 | top: 0; 36 | left: 0; 37 | transform: none; 38 | background: #f6ad55; 39 | height: 100%; 40 | width: 100%; 41 | border-radius: 2px; 42 | border: none; 43 | } 44 | 45 | .react-flow .react-flow__connectionline { 46 | z-index: 0; 47 | } 48 | 49 | .inputWrapper { 50 | display: flex; 51 | height: 20px; 52 | z-index: 1; 53 | position: relative; 54 | pointer-events: none; 55 | } 56 | 57 | .dragHandle { 58 | background: transparent; 59 | width: 14px; 60 | height: 100%; 61 | margin-right: 4px; 62 | display: flex; 63 | align-items: center; 64 | pointer-events: all; 65 | } 66 | 67 | .input { 68 | border: none; 69 | padding: 0 2px; 70 | border-radius: 1px; 71 | font-weight: 700; 72 | background: transparent; 73 | height: 100%; 74 | color: #222; 75 | pointer-events: none; 76 | } 77 | 78 | .input:focus { 79 | border: none; 80 | outline: none; 81 | background: rgba(255, 255, 255, 0.25); 82 | pointer-events: all; 83 | } 84 | -------------------------------------------------------------------------------- /src/App/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Edge, 3 | EdgeChange, 4 | NodeChange, 5 | OnNodesChange, 6 | OnEdgesChange, 7 | applyNodeChanges, 8 | applyEdgeChanges, 9 | XYPosition, 10 | InternalNode, 11 | } from '@xyflow/react'; 12 | import { create } from 'zustand'; 13 | import { nanoid } from 'nanoid/non-secure'; 14 | 15 | import { MindMapNode } from './types'; 16 | 17 | export type RFState = { 18 | nodes: MindMapNode[]; 19 | edges: Edge[]; 20 | onNodesChange: OnNodesChange; 21 | onEdgesChange: OnEdgesChange; 22 | updateNodeLabel: (nodeId: string, label: string) => void; 23 | addChildNode: (parentNode: InternalNode, position: XYPosition) => void; 24 | }; 25 | 26 | const useStore = create((set, get) => ({ 27 | nodes: [ 28 | { 29 | id: 'root', 30 | type: 'mindmap', 31 | data: { label: 'React Flow Mind Map' }, 32 | position: { x: 0, y: 0 }, 33 | dragHandle: '.dragHandle', 34 | }, 35 | ], 36 | edges: [], 37 | onNodesChange: (changes: NodeChange[]) => { 38 | set({ 39 | nodes: applyNodeChanges(changes, get().nodes), 40 | }); 41 | }, 42 | onEdgesChange: (changes: EdgeChange[]) => { 43 | set({ 44 | edges: applyEdgeChanges(changes, get().edges), 45 | }); 46 | }, 47 | updateNodeLabel: (nodeId: string, label: string) => { 48 | set({ 49 | nodes: get().nodes.map((node) => { 50 | if (node.id === nodeId) { 51 | // it's important to create a new node here, to inform React Flow about the changes 52 | return { 53 | ...node, 54 | data: { ...node.data, label }, 55 | }; 56 | } 57 | 58 | return node; 59 | }), 60 | }); 61 | }, 62 | addChildNode: (parentNode: InternalNode, position: XYPosition) => { 63 | const newNode: MindMapNode = { 64 | id: nanoid(), 65 | type: 'mindmap', 66 | data: { label: 'New Node' }, 67 | position, 68 | dragHandle: '.dragHandle', 69 | parentId: parentNode.id, 70 | }; 71 | 72 | const newEdge = { 73 | id: nanoid(), 74 | source: parentNode.id, 75 | target: newNode.id, 76 | }; 77 | 78 | set({ 79 | nodes: [...get().nodes, newNode], 80 | edges: [...get().edges, newEdge], 81 | }); 82 | }, 83 | })); 84 | 85 | export default useStore; 86 | -------------------------------------------------------------------------------- /src/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | import { 3 | ReactFlow, 4 | ConnectionLineType, 5 | NodeOrigin, 6 | OnConnectEnd, 7 | OnConnectStart, 8 | useReactFlow, 9 | useStoreApi, 10 | Controls, 11 | Panel, 12 | InternalNode, 13 | } from '@xyflow/react'; 14 | import { useShallow } from 'zustand/react/shallow'; 15 | 16 | import useStore, { RFState } from './store'; 17 | import MindMapNode from './MindMapNode'; 18 | import MindMapEdge from './MindMapEdge'; 19 | 20 | // we need to import the React Flow styles to make it work 21 | import '@xyflow/react/dist/style.css'; 22 | 23 | const selector = (state: RFState) => ({ 24 | nodes: state.nodes, 25 | edges: state.edges, 26 | onNodesChange: state.onNodesChange, 27 | onEdgesChange: state.onEdgesChange, 28 | addChildNode: state.addChildNode, 29 | }); 30 | 31 | const nodeTypes = { 32 | mindmap: MindMapNode, 33 | }; 34 | 35 | const edgeTypes = { 36 | mindmap: MindMapEdge, 37 | }; 38 | 39 | const nodeOrigin: NodeOrigin = [0.5, 0.5]; 40 | 41 | const connectionLineStyle = { stroke: '#F6AD55', strokeWidth: 3 }; 42 | const defaultEdgeOptions = { style: connectionLineStyle, type: 'mindmap' }; 43 | 44 | function Flow() { 45 | const store = useStoreApi(); 46 | const { nodes, edges, onNodesChange, onEdgesChange, addChildNode } = useStore( 47 | useShallow(selector) 48 | ); 49 | const { screenToFlowPosition } = useReactFlow(); 50 | const connectingNodeId = useRef(null); 51 | 52 | const getChildNodePosition = ( 53 | event: MouseEvent | TouchEvent, 54 | parentNode?: InternalNode 55 | ) => { 56 | const { domNode } = store.getState(); 57 | 58 | if ( 59 | !domNode || 60 | // we need to check if these properites exist, because when a node is not initialized yet, 61 | // it doesn't have a positionAbsolute nor a width or height 62 | !parentNode?.internals.positionAbsolute || 63 | !parentNode?.measured.width || 64 | !parentNode?.measured.height 65 | ) { 66 | return; 67 | } 68 | 69 | // we need to remove the wrapper bounds, in order to get the correct mouse position 70 | const panePosition = screenToFlowPosition({ 71 | x: 'clientX' in event ? event.clientX : event.touches[0].clientX, 72 | y: 'clientY' in event ? event.clientY : event.touches[0].clientY, 73 | }); 74 | 75 | // we are calculating with positionAbsolute here because child nodes are positioned relative to their parent 76 | return { 77 | x: panePosition.x - parentNode.internals.positionAbsolute.x, 78 | y: panePosition.y - parentNode.internals.positionAbsolute.y, 79 | }; 80 | }; 81 | 82 | const onConnectStart: OnConnectStart = useCallback((_, { nodeId }) => { 83 | // we need to remember where the connection started so we can add the new node to the correct parent on connect end 84 | connectingNodeId.current = nodeId; 85 | }, []); 86 | 87 | const onConnectEnd: OnConnectEnd = useCallback( 88 | (event) => { 89 | const { nodeLookup } = store.getState(); 90 | const targetIsPane = (event.target as Element).classList.contains( 91 | 'react-flow__pane' 92 | ); 93 | const node = (event.target as Element).closest('.react-flow__node'); 94 | 95 | if (node) { 96 | node.querySelector('input')?.focus({ preventScroll: true }); 97 | } else if (targetIsPane && connectingNodeId.current) { 98 | const parentNode = nodeLookup.get(connectingNodeId.current); 99 | 100 | if (parentNode) { 101 | const childNodePosition = getChildNodePosition(event, parentNode); 102 | 103 | if (childNodePosition) { 104 | addChildNode(parentNode, childNodePosition); 105 | } 106 | } 107 | } 108 | }, 109 | [getChildNodePosition] 110 | ); 111 | 112 | return ( 113 | 128 | 129 | 130 | React Flow Mind Map 131 | 132 | 133 | ); 134 | } 135 | 136 | export default Flow; 137 | --------------------------------------------------------------------------------