Build a Custom Low‑Code Flow Designer with React Flow and TypeScript
This tutorial walks through designing and implementing a low‑code workflow editor using React Flow Renderer, defining TypeScript data models, handling layout with Dagre, creating custom nodes and edges, enabling drag‑and‑drop node addition, deletion, configuration, and discussing common rendering challenges.
Flow Design
In low‑code scenarios, a workflow is an essential capability that lets users trigger asynchronous tasks through a form, such as a leave‑request process where the form submission starts a flow that advances only after each node is handled.
Data Interface Definition
export interface Node<T = any> {
id: ElementId;
position: XYPosition;
type?: string;
__rf?: any;
data?: T;
style?: CSSProperties;
className?: string;
targetPosition?: Position;
sourcePosition?: Position;
isHidden?: boolean;
draggable?: boolean;
selectable?: boolean;
connectable?: boolean;
dragHandle?: string;
}
export interface Edge<T = any> {
id: ElementId;
type?: string;
source: ElementId;
target: ElementId;
sourceHandle?: ElementId | null;
targetHandle?: ElementId | null;
label?: string | ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
style?: CSSProperties;
animated?: boolean;
arrowHeadType?: ArrowHeadType;
isHidden?: boolean;
data?: T;
className?: string;
}
export type FlowElement<T = any> = Node<T> | Edge<T>;
export interface Data {
nodeData: Record<string, any>;
businessData: Record<string, any>;
}
export interface WorkFlow {
version: string;
shapes: FlowElement<Data>[];
}The model defines a WorkFlow containing a version and an array of FlowElement objects, which can be either Node or Edge. Each node holds metadata ( nodeData) for rendering and business data ( businessData) for execution.
Flow Implementation
We use the open‑source library react-flow-renderer because it easily supports custom nodes, custom edges, and built‑in mini‑map controls.
Flow Layout
import store from './store';
import useObservable from '@lib/hooks/observable';
function App() {
const { elements } = useObservable(store);
const [dagreGraph, setDagreGraph] = useState(() => new dagre.graphlib.Graph());
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 90 });
elements?.forEach((el) => {
if (isNode(el)) {
return dagreGraph.setNode(el.id, {
width: el.data?.nodeData.width,
height: el.data?.nodeData.height,
});
}
dagreGraph.setEdge(el.source, el.target);
});
dagre.layout(dagreGraph);
const layoutedElements = elements?.map((ele) => {
const el = deepClone(ele);
if (isNode(el)) {
const nodeWithPosition = dagreGraph.node(el.id);
el.targetPosition = Position.Top;
el.sourcePosition = Position.Bottom;
el.position = {
x: nodeWithPosition.x - ((el.data?.nodeData.width || 0) / 2),
y: nodeWithPosition.y,
};
}
return el;
});
return (
<>
<ReactFlow className="cursor-move" elements={layoutedElements} />
</>
);
}The layout uses Dagre to compute node positions, then adjusts each node to be centered horizontally.
Custom Node
import React from 'react';
import { Handle, Position } from 'react-flow-renderer';
import Icon from '@c/icon';
import type { Data } from '../type';
function EndNodeComponent({ data }: { data: Data }): JSX.Element {
return (
<div className="shadow-flow-header rounded-tl-8 rounded-tr-8 rounded-br-0 rounded-bl-8 bg-white w-100 h-28 flex items-center cursor-default">
<section className="flex items-center p-4 w-full h-full justify-center">
<Icon name="stop_circle" className="mr-4 text-red-600" />
<span className="text-caption-no-color-weight font-medium text-gray-600">{data.nodeData.name}</span>
</section>
</div>
);
}
function End(props: any): JSX.Element {
return (
<>
<Handle type="target" position={Position.Top} isConnectable={false} />
<EndNodeComponent {...props} />
</>
);
}
export const nodeTypes = { end: End };The custom end node displays an icon and the node name, and disables incoming connections.
Custom Edge
import React from 'react';
import { getSmoothStepPath, getMarkerEnd, EdgeText, getEdgeCenter } from 'react-flow-renderer';
import cs from 'classnames';
import ToolTip from '@c/tooltip/tip';
import type { EdgeProps, FormDataData } from '../type';
import './style.scss';
export default function CustomEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
label,
arrowHeadType,
markerEndId,
source,
target,
}: EdgeProps): JSX.Element {
const edgePath = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 0,
});
const markerEnd = getMarkerEnd(arrowHeadType, markerEndId);
const [centerX, centerY] = getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const formDataElement = elements.find(({ type }) => type === 'formData');
const hasForm = !!(formDataElement?.data?.businessData as FormDataData)?.form.name;
const cursorClassName = cs({ 'cursor-not-allowed': !hasForm });
return (
<>
<g className={cs('react-flow__edge-path cursor-pointer pointer-events-none', cursorClassName)}>
<path id={id} style={{ ...style, borderRadius: '50%' }} d={edgePath} markerEnd={markerEnd} />
{status === 'DISABLE' && (
<EdgeText
className={cursorClassName}
style={{ filter: 'drop-shadow(0px 8px 24px rgba(55,95,243,1))', pointerEvents: 'all' }}
x={centerX}
y={centerY}
label={label}
/>
)}
</g>
{!hasForm && (
<foreignObject className="overflow-visible workflow-node--tooltip" x={centerX + 20} y={centerY - 10} width="220" height="20">
<ToolTip
label="请为开始节点选择一张工作表"
style={{ transform: 'none', backgroundColor: 'transparent', alignItems: 'center' }}
labelClassName="whitespace-nowrap text-12 bg-gray-700 rounded-8 text-white pl-5"
>
<span />
</ToolTip>
</foreignObject>
)}
</>
);
}
export const edgeTypes = { plus: CustomEdge };The edge shows a clickable plus sign in the middle; when the associated form is missing, the edge becomes non‑interactive and displays a tooltip.
Node Builder
function nodeBuilder(id: string, type: string, name: string, options: Record<string, any>) {
return {
id,
type,
data: {
nodeData: { name },
businessData: getNodeInitialData(type),
},
};
}
const endNode = nodeBuilder(endID, 'end', '结束', {
width: 100,
height: 28,
parentID: [startID],
childrenID: [],
});
elements.push(endNode);Edge Builder
export function edgeBuilder(
startID?: string,
endID?: string,
type = 'plus',
label = '+',
): Edge {
return {
id: `e${startID}-${endID}`,
type,
source: startID as string,
target: endID as string,
label,
arrowHeadType: ArrowHeadType.ArrowClosed,
};
}
const edge = edgeBuilder('startNodeId', 'end');
elements.push(edge);Add Node
Nodes are added by dragging a component from the side drawer onto a plus‑edge. The drop handler parses the transferred data, creates a new node with nodeBuilder, inserts two new edges, removes the original edge, and updates the store.
function onDrop(e: DragEvent): Promise<void> {
e.preventDefault();
if (!e?.dataTransfer) return;
const { nodeType, width, height, nodeName } = JSON.parse(
e.dataTransfer.getData('application/reactflow')
);
const { source, target, position } = currentConnection;
if (!source || !target || !position) return;
addNewNode({ nodeType, width, height, nodeName, source, target, position });
updateStore((s) => ({ ...s, currentConnection: {} }));
}
function addNewNode({ nodeType, width, height, nodeName, source, target, position }) {
const id = nodeType + nanoid();
const newNode = nodeBuilder(id, nodeType, nodeName, {
width,
height,
parentID: [source],
childrenID: [target],
position: getCenterPosition(position, width, height),
});
let newElements = elements.concat([newNode, edgeBuilder(source, id), edgeBuilder(id, target)]);
newElements = removeEdge(newElements, source, target);
setElements(newElements);
}Delete Node
Each custom node includes a delete button. Clicking it removes the node and its incident edges, then reconnects the predecessor and successor to keep the graph connected.
function onRemoveNode(nodeID: string, elements: FlowElement<Data>[]): FlowElement<Data>[] {
const elementToRemove = elements.find((element) => element.id === nodeID);
const { parentID, childrenID } = elementToRemove?.data?.nodeData || {};
const edge = edgeBuilder(parentID, childrenID);
const newElements = removeElements([elementToRemove], elements);
return newElements.concat(edge);
}
export function NodeRemover({ id }: { id: string }) {
const { elements } = useObservable(store);
function onRemove() {
const newElements = onRemoveNode(id, elements);
updateStore((s) => ({ ...s, elements: newElements }));
}
return <Icon name="close" onClick={onRemove} />;
}Node Configuration
Clicking a node opens a side drawer with a form bound to the node’s businessData. Submitting the form writes the data back to the node; cancelling simply closes the drawer.
function ConfigForm() {
const { nodeIdForDrawerForm, elements } = useObservable(store);
const currentNodeElement = elements.find(({ id }) => id === nodeIdForDrawerForm);
const defaultValue = currentNodeElement?.data?.businessData;
function onSubmit(data: BusinessData) {
const newElements = elements.map((element) => {
if (element.id === nodeIdForDrawerForm) {
element.data.businessData = data;
}
return element;
});
updateStore((s) => ({ ...s, elements: newElements }));
}
function closePanel() {
updateStore((s) => ({ ...s, nodeIdForDrawerForm: '' }));
}
return <Form defaultValue={defaultValue} onSubmit={onSubmit} onCancel={closePanel} />;
}Problems and Challenges
Edges can be hidden behind nodes, making them invisible.
Edges may intersect each other, producing a tangled diagram.
React‑flow‑renderer does not provide built‑in path‑finding to avoid these issues; custom algorithms are required for complex graphs.
Conclusion
This article covered the requirements of a flow designer, defined the TypeScript data interfaces, and built a functional prototype using React Flow Renderer. Real‑world applications will need further customization to handle specific business rules and complex layout challenges.
Qingyun Technology Community
Official account of the Qingyun Technology Community, focusing on tech innovation, supporting developers, and sharing knowledge. Born to Learn and Share!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
