Skip to content

Product Engineering

React Flow / @xyflow/react — Building the Velocity X Remarketing Visual Canvas

Production Patterns for Visual Node Editors

⚙️ 🎯

If you've been living under a rock, React Flow (now `@xyflow/react`) is the go-to library for building visual node editors in the browser — flowcharts, DAGs, email sequences, CI/CD pipelines, anything where you drag nodes onto a canvas and wire them together. Velocity X's email-remarketing flow editor runs on it. Four custom node types, animated edges that glow when a contact flows through them, full drag-drop UX, serialised as JSONB and executed hourly. This piece covers why @xyflow/react beats raw Konva or SVG, the custom node pattern, edge animation on state change, and how to save/load a graph cleanly.

Why @xyflow/react Over SVG or Konva

Building a node editor from scratch means solving pan/zoom, mouse event bubbling, node selection, multi-select, undo/redo, and viewport math. Konva gives you a 2D canvas renderer and handles some of that. Raw SVG makes you manage all of it by hand. @xyflow/react ships with all of it: pan, zoom, node/edge selection, multi-select, keyboard shortcuts, connection validation, and a plugin system for custom behaviour.

Real advantage: the connection UI is seamless. Drag a handle out of a node, the library renders a connection edge following your cursor in real time, validates whether the target handle accepts the connection, and snaps it on drop. Try that in SVG without 100 lines of math.

Four Custom Node Types in Velocity X

Each node is a React component wrapped in a `` (the connection points on the node itself). Behold the pattern:

// AudienceNode.tsx
import { Handle, Position } from '@xyflow/react';

interface AudienceNodeProps {
  data: { segment: string };
}

export function AudienceNode({ data }: AudienceNodeProps) {
  return (
    <div className="bg-blue-100 border-2 border-blue-600 rounded-lg p-4 w-48">
      <div className="font-semibold text-blue-900">Audience</div>
      <div className="text-xs text-blue-700 mt-1">{data.segment}</div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

The `` is the drag point. `type="source"` means edges originate here; a branch node has both source and target. Four nodes: Audience (filter contacts), Wait (delay in hours), Send (pick email template), Branch (on click/open/nothing). Each data shape is Zod-validated on the frontend.

Edge Animation When Contacts Flow

Static edges are boring. When the scheduler executes a flow and a contact moves from node A to node B, Velocity X subscribes to a Supabase real-time update, receives the state change, and animates the edge. @xyflow/react edges accept an `animated` prop and `style` object:

// In your edges array
edges.map(edge => ({
  ...edge,
  animated: activeContactPath.includes(edge.id),
  style: { stroke: activeContactPath.includes(edge.id) ? '#06b6d4' : '#e5e7eb' }
}))

When a contact enters a node, highlight the inbound edge and animate a 2-second glow via CSS keyframes on the SVG ``. Users watch contacts flow through the sequence in real time. It's not required for function, but it's 30 seconds of visual feedback that makes the product feel alive.

Serialisation: Nodes + Edges to JSONB

@xyflow/react's `getNodes()` and `getEdges()` methods return plain objects. Serialisation is trivial:

const graph = {
  nodes: getNodes(),
  edges: getEdges()
};

// Save to DB
const { error } = await supabase
  .from('flows')
  .update({ graph })
  .eq('id', flowId);

// Load from DB
const { data: flow } = await supabase.from('flows').select('graph');
setNodes(flow.graph.nodes);
setEdges(flow.graph.edges);

That's it. No custom serialisation logic. The Zod schemas on each node type validate shape on load, and you have a deterministic round-trip.

Connection Validation

You don't want a Send node connecting to an Audience node (wrong direction). @xyflow/react's `isValidConnection` callback stops invalid edges before they snap:

const isValidConnection = (conn) => {
  const sourceNode = getNode(conn.source);
  const targetNode = getNode(conn.target);
  const validTransitions = {
    'audience': ['wait'],
    'wait': ['send_email'],
    'send_email': ['branch'],
    'branch': ['send_email', 'audience']
  };
  return validTransitions[sourceNode.type].includes(targetNode.type);
};

Now the canvas enforces flow topology. Invalid connections get rejected mid-drag. Users build valid sequences by accident.

FAQs

Do I need to write custom SVG for edges?

Not usually. @xyflow/react uses Bezier curves by default and exposes `connectionLineType` (Bezier, straight, step) globally. For fancy animated gradients or custom arrowheads, you can write an edge component, but that's a rarity.

How do I handle large graphs with 100+ nodes?

@xyflow/react virtualizes the viewport — it only renders nodes and edges in view. Beyond 1000 nodes the canvas gets sluggish, but anything under 500 is fine on modern hardware. If you hit a scaling ceiling, split the graph into subflows.

Can I have nested nodes (subgraphs)?

Not out of the box, but you can fake it: a "subflow" node whose data references another flow ID, and when clicked, the canvas swaps to that flow. Popular in complex workflows.

How do I undo/redo?

Implement a history stack (Zustand works). Every time nodes or edges change, push a snapshot to the stack. On undo, pop and restore. @xyflow/react doesn't ship undo, but the pattern is standard and cheap.

The Bottom Line

If you're building anything beyond a static flowchart, @xyflow/react saves you weeks of canvas math and interaction code. For email sequences, DAG visualisations, or any domain-specific flow editor, it's the right abstraction. Velocity X's editor is production-live, running 50+ flows daily, and the code is clean because the library did the heavy lifting. Don't reinvent this wheel.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.