DEV Community

Cover image for React Flow alternative for Angular: meet ngDiagram
Wojciech Krzesaj for ngDiagram

Posted on • Originally published at synergycodes.com

React Flow alternative for Angular: meet ngDiagram

React Flow has grown into the default for node-based UIs in React since its 2019 origins. Its maker, xyflow, also ships a Svelte version, Svelte Flow, but no Angular one. If you work in Angular and reach for React Flow, that gap is the wall you hit. Angular has a signals-native answer: ngDiagram, a React Flow alternative built for Angular.

Disclosure: I work at Synergy Codes, the team behind ngDiagram. I've built with both ngDiagram and React Flow, so this comparison comes from using each.

In short

React Flow is React-only. Using it inside Angular means bridging 2 runtimes. ngDiagram is a native Angular diagram library built on signals, dependency injection, and OnPush: one runtime, no bridge. The library is open source under Apache 2.0, first published to npm in August 2025, out of beta that November, with a stable 1.0 in February 2026.

Why not just wrap React Flow?

Angular teams building workflow builders, org charts, pipeline editors, or data-flow canvases need a library to build the canvas on. Framework-agnostic options like GoJS and JointJS mean driving the diagram imperatively from outside Angular. GoJS is commercially licensed, while JointJS keeps its advanced tooling in the paid JointJS+ tier. React Flow is the most popular node-based UI library in React, so it keeps coming up.

React Flow's limit in Angular is that it ships no Angular build. Its only way into an Angular app is a React island: an isolated React root inside your page, shipping react, react-dom, and React Flow alongside Angular, reached through a hand-built bridge or a third-party wrapper.

Angular app with ngDiagram on the left as one Angular box; Angular app with React Flow on the right as an Angular box, a bridge, and a React box stacked vertically.

The framework boundary, drawn out: ngDiagram sits inside Angular, while React Flow lives in a separate React box you bridge to.

Wrapping works, and the diagram itself is fine. The cost sits at the boundary, in 3 places:

  • A second runtime. Rendering React Flow means shipping react and react-dom, code an Angular app would never load otherwise.
  • State stuck on the React side. React Flow keeps state inside React, where Angular's change detection can't see it. Every selection, drag, and connection you need in Angular you carry across by hand (more still on Zone.js, with runOutsideAngular() and ngZone.run()).
  • Maturity that stays on the React side. React Flow is proven in React apps. The bridge that holds it inside Angular is yours to write and keep working.

All three are the same problem: React Flow is not Angular. Remove the boundary and the costs go with it.

React Flow's mental model, native in Angular

If you know React Flow, ngDiagram will feel familiar. The mental model carries over: nodes, edges, a map of custom node types, and connection points on each node. ngDiagram needs Angular 18+, and its only runtime dependency is tslib. The foundation is pure Angular and a single runtime.

The difference is what those pieces are in Angular: a custom node is an ordinary Angular component that renders through Angular directly, with nothing to sync. The model is a swappable ModelAdapter, so your own store can be the single source of truth.

The same diagram, both ways

Before any custom code, here is the smallest thing each library does: a diagram with 2 nodes and an edge. A direct comparison of the minimal setups shows where the runtime difference lives.

React Flow uses hooks and (typically) controlled state:

// React Flow
import { useState, useCallback } from "react";
import {
  ReactFlow,
  applyNodeChanges,
  applyEdgeChanges,
  addEdge,
  Position,
  type Node,
  type Edge,
  type OnNodesChange,
  type OnEdgesChange,
  type OnConnect,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";

const initialNodes: Node[] = [
  {
    id: "1",
    position: { x: 100, y: 150 },
    data: { label: "Node 1" },
    // side handles, so the edge runs horizontally
    sourcePosition: Position.Right,
    targetPosition: Position.Left,
  },
  {
    id: "2",
    position: { x: 400, y: 150 },
    data: { label: "Node 2" },
    sourcePosition: Position.Right,
    targetPosition: Position.Left,
  },
];
const initialEdges: Edge[] = [{ id: "e1", source: "1", target: "2" }];

export default function DiagramComponent() {
  const [nodes, setNodes] = useState<Node[]>(initialNodes);
  const [edges, setEdges] = useState<Edge[]>(initialEdges);
  const onNodesChange: OnNodesChange = useCallback(
    (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
    [],
  );
  const onEdgesChange: OnEdgesChange = useCallback(
    (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
    [],
  );
  const onConnect: OnConnect = useCallback(
    (params) => setEdges((eds) => addEdge(params, eds)),
    [],
  );

  return (
    <div style={{ width: "100%", height: 300 }}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

ngDiagram is a standalone component and a provider. State is a model you initialize:

// ngDiagram
// styles.scss: @import "ng-diagram/styles.css";
import { Component } from "@angular/core";
import {
  NgDiagramComponent,
  initializeModel,
  provideNgDiagram,
} from "ng-diagram";

@Component({
  imports: [NgDiagramComponent],
  providers: [provideNgDiagram()],
  template: `<ng-diagram [model]="model" />`,
  styles: `
    :host {
      display: flex;
      height: 300px;
    }
  `,
})
export class DiagramComponent {
  model = initializeModel({
    nodes: [
      { id: "1", position: { x: 100, y: 150 }, data: { label: "Node 1" } },
      { id: "2", position: { x: 400, y: 150 }, data: { label: "Node 2" } },
    ],
    edges: [
      {
        id: "e1",
        source: "1",
        // port ids shipped by the default node template
        sourcePort: "port-right",
        target: "2",
        targetPort: "port-left",
        data: {},
      },
    ],
  });
}
Enter fullscreen mode Exit fullscreen mode

The two setups look almost the same: declare your nodes and edges, then hand them to a component. The difference shows up in Angular, where the ngDiagram version runs as written and the React Flow version needs a bridge first. You wrap the React root in a Web Component and push state in through a setter per prop. Events wire back out by hand, and you own the teardown.

The whole example is 2 nodes and an edge. Custom nodes are the next step.

A custom node, both ways

The setup is the same in both libraries: build a component, map it to a type name, then bind the map to the canvas. Each does it the way native to its own framework.

In React Flow, the node's props are typed as NodeProps, and its connection points are <Handle> elements:

// React Flow
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";

export function CustomNode({ data }: NodeProps<Node<{ label: string }>>) {
  return (
    <div className="custom-node">
      <Handle type="target" position={Position.Left} />
      <span>{data.label}</span>
      <Handle type="source" position={Position.Right} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

You register the component in a nodeTypes map, then pass that map to the canvas. A node renders with it when its type matches the registered key:

function DiagramComponent() {
  const nodeTypes = useMemo(() => ({ customNode: CustomNode }), []);

  return <ReactFlow nodeTypes={nodeTypes} nodes={nodes} edges={edges} />;
}
Enter fullscreen mode Exit fullscreen mode

In ngDiagram, the node arrives as a typed input, and its connection points are <ng-diagram-port> elements:

// ngDiagram
import { Component, input } from "@angular/core";
import {
  NgDiagramPortComponent,
  type NgDiagramNodeTemplate,
  type Node,
} from "ng-diagram";

@Component({
  imports: [NgDiagramPortComponent],
  template: `
    <div class="custom-node">{{ node().data.label }}</div>
    <ng-diagram-port id="port-left" type="target" side="left" />
    <ng-diagram-port id="port-right" type="source" side="right" />
  `,
})
export class CustomNodeComponent implements NgDiagramNodeTemplate<{
  label: string;
}> {
  node = input.required<Node<{ label: string }>>();
}
Enter fullscreen mode Exit fullscreen mode

You register the component in an NgDiagramNodeTemplateMap, then bind that map to the diagram. The node's type selects its template, just as nodeTypes does in React Flow:

@Component({
  template: `<ng-diagram
    [model]="model"
    [nodeTemplateMap]="nodeTemplateMap"
  />`,
})
export class DiagramComponent {
  nodeTemplateMap = new NgDiagramNodeTemplateMap([
    ["customNode", CustomNodeComponent],
  ]);
}
Enter fullscreen mode Exit fullscreen mode

The node can reach the rest of your app through dependency injection. Inject a service and use it. The example uses an OrderService, the same service your forms and tables already call:

@Component({
  template: `<div class="custom-node">{{ status() }}</div>`,
})
export class CustomNodeComponent implements NgDiagramNodeTemplate<{
  orderId: string;
}> {
  private orders = inject(OrderService);
  node = input.required<Node<{ orderId: string }>>();

  status = computed(() => this.orders.statusOf(this.node().data.orderId));
}
Enter fullscreen mode Exit fullscreen mode

Because status is a signal, the node updates when the order changes.

When I built an app with ngDiagram, the reuse showed up right away. Generic components I already shipped elsewhere in the product (forms, cards, status widgets) dropped into nodes and just worked.

How ngDiagram and React Flow handle extensibility

Sooner or later every diagram app needs a behavior its library doesn't ship. What happens then (extend, or fork) depends on the library's extensibility model. The two libraries take different routes.

React Flow extends through its React surface. The <ReactFlow> component exposes a wide prop surface (interaction flags, connection rules, styling), and in controlled mode the change callbacks (onNodesChange, onEdgesChange) and connection events (onConnect) let you intercept and reshape changes before you apply them. Beyond that, it is hooks and component composition.

ngDiagram has 3 layers. Most needs are already in the global config: snapping, zoom, grouping, linking, edge routing, keyboard shortcuts, and validation callbacks, all adjustable at runtime. Events like nodeDragEnded and selectionChanged cover reacting to what users do. And when config and events aren't enough, every model change runs through a middleware pipeline. This is how you extend ngDiagram without forking it: I've used the pipeline to add behavior the library doesn't ship by default, like connecting edges to other edges.

Locking a node to horizontal movement shows the pipeline in a few lines: intercept the moved nodes, keep the new X, and restore the Y they started at.

// Horizontal movement lock in ngDiagram
import type { Middleware } from "ng-diagram";

export const horizontalLock: Middleware<"horizontal-lock"> = {
  name: "horizontal-lock",
  execute: (context, next) => {
    const movedIds = context.helpers.getAffectedNodeIds(["position"]);
    if (!movedIds.length) {
      next();
      return;
    }
    const nodesToUpdate = movedIds.map((id) => ({
      id,
      position: {
        x: context.nodesMap.get(id)!.position.x,
        y: context.initialNodesMap.get(id)!.position.y,
      },
    }));
    next({ nodesToUpdate });
  },
};
Enter fullscreen mode Exit fullscreen mode

You register it alongside the defaults and pass it to the diagram:

@Component({
  template: ` <ng-diagram [model]="model" [middlewares]="middlewares" /> `,
})
export class MyDiagramComponent {
  middlewares = createMiddlewares((defaults) => [...defaults, horizontalLock]);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The library stays untouched and your behavior sits on top of it.

A node dragged across the canvas, moving only left and right while its vertical position stays fixed

A dragged node moves only horizontally. The middleware above keeps its new X and restores the Y it started at.

In React Flow the same rule goes in the controlled-mode onNodesChange callback: reset each position change's Y to the node's start value, captured in onNodeDragStart and held in a ref. A per-node extent can pin movement too, but the callback is the general mechanism for arbitrary rules.

// Horizontal movement lock in React Flow
// startYOf: start positions captured in onNodeDragStart, kept in a ref
const onNodesChange = useCallback((changes: NodeChange[]) => {
  const locked = changes.map((change) =>
    change.type === "position" && change.position
      ? {
          ...change,
          position: { x: change.position.x, y: startYOf(change.id) },
        }
      : change,
  );
  setNodes((nds) => applyNodeChanges(locked, nds));
}, []);
Enter fullscreen mode Exit fullscreen mode

Both work. The difference is where the rule lives. In React Flow it sits in the component that renders the flow. In ngDiagram it registers once and applies to every change, whatever triggered it.

The middleware guide covers the full pipeline: intercepting, transforming, or canceling any change.

ngDiagram vs React Flow, feature by feature

ngDiagram covers the same core as React Flow, with a feature set shaped by its creators' decade of client diagramming work: the capabilities that kept proving necessary in real projects. I used those features in my own project, and they covered what I needed. For an Angular team, the deciding factor is how natively each feature fits.

Feature ngDiagram React Flow
Custom nodes Angular component (NgDiagramNodeTemplate), registered in a node-type map; full Angular inside: DI and services React component (NodeProps), registered in a node-type map (nodeTypes)
Custom edges Angular component (NgDiagramEdgeTemplate), registered in an edge-type map; SVG path via NgDiagramBaseEdgeComponent React component, registered in an edge-type map (edgeTypes); SVG path via path helpers (getBezierPath etc.) + <BaseEdge>
Ports / handles Named connection points via <ng-diagram-port> Named connection points via <Handle>
Edge routing Built-in orthogonal, bezier, polyline; custom via NgDiagramService.registerRouting() Built-in bezier, straight, step, smoothstep, simplebezier; custom via path helpers + <BaseEdge>
Auto-layout No built-in layout; documented ELK.js integration No built-in layout; documented dagre, elkjs, d3-hierarchy integrations
State & store Built-in signal model (initializeModel). Swap in a ModelAdapter to make your own store the single source of truth Built-in Zustand store; controlled mode lets your store drive nodes/edges, but the internal store stays (not replaceable)
Extensibility Config, callbacks, and events for most cases
Middleware pipeline for any model change
Extend through React composition, hooks, change/event callbacks, and helper components (Panel, NodeToolbar, ViewportPortal)
Undo / redo Not built in yet; Ctrl/Cmd+Z / Ctrl/Cmd+Y are reserved and on the public roadmap (needs a custom model today) Not built in; you implement it yourself (a paid Pro example exists)
Transactions transaction() batches many changes into one atomic model update: fewer renders, consistent state, and groundwork for undo/redo No transaction API; batch changes yourself in a single state update
Performance Opt-in viewport virtualization via virtualization.enabled, default off Opt-in viewport virtualization via onlyRenderVisibleElements, default off
Grouping Group nodes that contain child nodes via groupId Group nodes that contain child nodes via parentId
Palette Built-in palette components for drag-and-drop node creation, with live drag preview No built-in palette; build one following the drag-and-drop example
Resize Built-in resize handles via <ng-diagram-node-resize-adornment> Built-in resize handles via <NodeResizer>
Rotation Built-in rotation handle via <ng-diagram-node-rotate-adornment> No built-in rotation; build one following the rotatable-node example
Minimap Minimap <ng-diagram-minimap>: node overview, viewport indicator, pan/zoom navigation Minimap <MiniMap>: node overview, viewport indicator, pan/zoom navigation
Theming CSS variables; light and dark out of the box; Tailwind supported CSS variables; light and dark via colorMode (default light); Tailwind supported
Touch / mobile Supported: pinch-zoom, 2-finger pan, long-press select, tap/drag/resize/rotate, drag-connect, tap-to-connect via linking mode (startLinking()) Supported: pinch-zoom, pan, tap-to-connect, drag-connect, auto-pan, connection radius
Accessibility Not built in yet; full support is on the public roadmap. Keyboard shortcuts cover editing (copy/paste, move, zoom), not navigation. Nodes are real DOM, so you can add ARIA to node content today Documented built-in a11y: focusable nodes/edges, keyboard move, ARIA labels
AI-assisted dev Official @ng-diagram/mcp server puts the docs and API into your AI assistant No official MCP server (a deliberate docs-first choice); publishes llms.txt docs endpoints for AI assistants instead
Bundle One library; only runtime dependency is tslib; no extra framework in the bundle The library plus the React runtime (react + react-dom) an Angular app would not otherwise ship

Current bundle sizes: bundlephobia for ng-diagram and @xyflow/react.

How mature is ngDiagram?

ngDiagram hit a stable v1.0 in February 2026 and is at v1.2 as of mid-2026. It is open source under Apache 2.0, tracks the latest 3 Angular versions, and is built by Synergy Codes.

Development has not slowed since: each release brings DX improvements and features the community asked for.

Beyond examples, the team ships maintained starter-kit templates that you clone and customize for your own use case, with more to come. They come from the same place as the feature set: what client work kept proving necessary.

Developers are already building the first projects using it, collected in the growing showcases.

The library has been well received: it has passed 500 GitHub stars, the community has started writing its own tutorials on Medium, and the official Angular account shared ngDiagram in February 2026.

The official Angular account sharing ngDiagram, February 2026

The official Angular account sharing ngDiagram, February 2026 (source).

When to choose which, and how to start

The rule tracks your framework, with one exception:

Your stack Recommendation Why
Your app is React React Flow Mature, MIT-licensed, proven in production
A new Angular build ngDiagram No React layer to ship or bridge
Angular already running a React Flow island Keep it. Treat as tech debt, plan a migration The island works. The second runtime, bridge, and state sync stay as ongoing cost

For a fresh Angular build, I don't see a reason to start with React Flow. The framework boundary alone is enough to prefer a native option. Wrapping is usually chosen for React Flow's maturity. The question is whether the boundary cost is worth it in your app.

The exception is if you already run React Flow inside Angular. A rewrite tomorrow is rarely worth the risk. Keep it, and revisit when the bridge starts to bite: a major framework upgrade, nodes that need deeper access to your Angular app, or the next big diagram feature.

Sticking with what you know is reasonable, but in Angular, React Flow adds one more thing to maintain: the bridge between the 2 frameworks. A native library drops it. Try ngDiagram and see how it feels. Adding it takes one install, one provider, and a styles import. Start with the interactive examples.

Building a custom diagram editor, or taking an existing one further, is the kind of work Synergy Codes does.

Top comments (0)