Skip to content
On this page

ngrx-graph: Static Analysis and Graph Visualization for NgRx Applications

ngrx-graph: Static Analysis and Graph Visualization for NgRx ApplicationsAI-generated image

If you have ever traced an action through a sprawling NgRx codebase, wondering which effect listens to it, which reducer handles it, and whether some other effect silently dispatches another action in the chain, you know the pain. Manual code traversal works until it does not. One missed dispatch() call buried in a mergeMap. Your mental model breaks.

ngrx-graph is a CLI tool that turns that chaos into pictures. It scans your Angular NgRx project, builds a structured JSON representation of every Component, Action, Effect, and Reducer, and generates DOT and SVG graphs that make action flows visible at a glance.

The Problem: Following NgRx Actions by Hand

NgRx is powerful. It gives you a predictable state container with clear separation of concerns: actions describe what happened, effects handle side effects, reducers decide how state changes. In theory, the data flow is clean.

In practice, large NgRx applications accumulate dozens (sometimes hundreds) of actions. Effects chain actions together. Components dispatch actions that trigger effects that dispatch more actions. Reducers listen to specific action types. And somewhere in the middle, an action gets aliased through a barrel export and re-exported under a different name.

Here is what the typical debugging session looks like:

  1. You find a dispatch(SomeAction) in a component.
  2. You search for where SomeAction is handled: maybe an effect, maybe a reducer.
  3. The effect does something and dispatches AnotherAction.
  4. You repeat the process for AnotherAction.
  5. Three levels deep, you lose track.

This is not a theoretical problem. In production NgRx codebases, action chains routinely span 3 to 5 levels of indirection. Effects compose effects. Nested actions (actions created by dispatching other actions) add another layer. The mental overhead is real, and it leads to bugs: missed dispatches, stale state, and duplicate logic.

What you need is a map. Not a diagram someone drew once and forgot to update: a generated map, built from the actual source code, every time.

Without ngrx-graph: 15 minutes across 8 tab switches in VS Code tracing ofType(), barrel exports, and dispatch() calls.

With ngrx-graph: Run npx ngrx-graph "LoadUsers" --svg and inspect the complete execution path in 2 seconds.

High-Level Architecture

ngrx-graph is a Node.js CLI built with oclif. The pipeline runs in three stages:

  1. Scanning: TypeScript Compiler API parses source files, extracting action definitions, effect chains, reducer handlers, and component dispatches via AST traversal.
  2. Intermediate JSON: All scan results serialize to ngrx-graph.json, a canonical representation that decouples scanning from rendering.
  3. Graph Generation: The JSON feeds into Graphviz DOT generation, with optional SVG rendering via native dot or a WASM fallback.
Source Files --> AST Scanners --> ngrx-graph.json --> DOT/SVG Renderer
     |               |                  |                    |
  *.ts files    p-limit workers    canonical format    Graphviz or viz.js

Each stage is independently usable. You can stop after JSON to inspect scanner output. You can reuse the DOT files with any Graphviz-compatible tool. The architecture keeps concerns separated so that improvements to scanning do not ripple into rendering and vice versa.

Installation and Quick Start

Install globally or use npx:

npm install -g ngrx-graph
# or
npx ngrx-graph ...

Common Workflows

# JSON only: inspect what the scanner finds
ngrx-graph -d ./src --out ./out
# writes: ./out/ngrx-graph.json

# Focused graph for a specific action
ngrx-graph "LoadUsers" -d ./src --out ./out --svg
# writes: ./out/LoadUsers.dot + ./out/LoadUsers.svg

# Full project graph
ngrx-graph -a -d ./src --out ./out --dot --svg
# writes: ./out/all.dot + ./out/all.svg

# Force re-scan (bypass JSON cache)
ngrx-graph -d ./src --out ./out -f

Complete Flag Reference

FlagShortDescription
--dir-dDirectory to scan (default: cwd)
--out-oOutput directory for ngrx-graph.json
--json-jScan and write JSON only, no DOT/SVG
--dotGenerate DOT files (per-action + aggregated)
--svg-sGenerate SVG files from DOT
--vizPrefer viz.js (WASM) for SVG when dot binary is unavailable
--all-aGenerate only the aggregated all.dot
--concurrency-cParallel file parsing workers (default: CPU count - 2)
--force-fForce re-scan, ignore cached JSON
--verbose-vEnable verbose logging

Tips for Large Projects

For codebases with thousands of files, a few practices make the difference between a 2-second scan and a 20-second one:

  • Start with JSON: Run ngrx-graph -d ./src --json first to inspect what the scanner finds. Review ngrx-graph.json before generating graphs.
  • Cache by default: Subsequent runs reuse the existing JSON. Only pass --force after significant refactors.
  • Tune concurrency: On CI machines with limited CPU, lower concurrency with --concurrency 2 to avoid resource contention.
  • Use --viz without Graphviz installed: The --viz flag uses viz.js (a WASM/JS renderer) instead of the native dot binary. Falls back gracefully.
  • Focus your graphs: The --all flag produces the full project graph. For debugging a specific action, always pass the action name to get a focused subgraph. Full graphs with 500+ nodes are technically correct but practically unreadable.

Under the Hood: Solving the AST and Graph Hard Problems

Building a reliable NgRx action graph from static source analysis required solving three non-trivial engineering problems. Each one defeated naive approaches and demanded a deliberate algorithmic solution.

Challenge 1: Static Analysis of RxJS Chains in Effects

Effects are the hardest part of the graph. A typical effect looks like this:

loadUsers$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUsers),
    switchMap(() =>
      this.userService.getAll().pipe(
        map(users => loadUsersSuccess({ users }))
      )
    )
  )
);

The scanner must determine two things: which actions enter via ofType(), and which actions exit via dispatch() or return inside operator callbacks. The difficulty is that RxJS operators are deeply nested callback functions. The action dispatched inside map() or mergeMap() is not a sibling of the ofType() call; it is several levels of function nesting deeper.

The solution is a recursive AST walk over CallExpression nodes. Starting from each createEffect() body, the scanner identifies pipe() calls, walks each operator argument, and matches ofType invocations to extract input actions:

// Conceptual AST node traversal for Effect inputs
if (ts.isCallExpression(node) && node.expression.getText() === "ofType") {
  const inputAction = node.arguments[0]?.getText();
  // Register input action...
}

When the walker encounters map, mergeMap, switchMap, concatMap, or exhaustMap, it descends into the callback body. Inside the callback, it searches for dispatch(SomeAction) calls and return SomeAction expressions, recording each as an output action.

This approach handles arbitrarily nested operator chains: pipe(switchMap(() => pipe(map(X), tap(() => dispatch(Y))))) resolves correctly because the walker does not care about nesting depth. It tracks the chain structurally, not textually.

The scanner also handles the common pattern where an effect dispatches an action conditionally. Every dispatch() call within the operator body, regardless of its position in if/else branches, is captured as a possible output. This is conservative by design: it is better to show an action that might fire than to miss one that does.

Challenge 2: Resolving Barrel File Aliases

Angular projects commonly organize actions in dedicated files and re-export them through barrel files (index.ts). The problem arises when re-exports use aliases:

// actions/user.actions.ts
export const loadUsers = createAction('[Users] Load');
export const loadUsersSuccess = createAction('[Users] Load Success');
// index.ts
export { loadUsers, loadUsersSuccess as usersLoaded } from "./actions/user.actions";
// user.effects.ts
import { usersLoaded } from "../";

The effect references usersLoaded, but the canonical action name is loadUsersSuccess. A naive scanner treats these as two unrelated actions. The graph fractures into disconnected subgraphs, and the user sees an incomplete picture.

ngrx-graph solves this with an in-memory canonical symbol table built during the scanning phase:

  1. Declaration scan: First pass identifies all createAction() calls and records their canonical names and source file locations.
  2. Re-export scan: Second pass walks all index.ts files, parsing export { X as Y } and export { X } patterns. For each re-export, it maps the alias (Y) back to the canonical name (X).
  3. Resolution: When any scanner encounters an imported action name, it checks the symbol table. If the name is an alias, it resolves to the canonical name before recording it in the graph.

This three-pass approach handles multi-level re-exports (A re-exports from B which re-exports from C) and mixed alias patterns. The symbol table is the single source of truth for action identity across the entire codebase.

Challenge 3: Graph Noise and Reachability Filtering

A project-wide NgRx graph with hundreds of actions is not useful. The node count makes DOT layouts unreadable, and most nodes are irrelevant to whatever you are investigating. The engineering problem is extracting a meaningful subgraph from a large directed graph.

[ Disconnected Action ]   [ Angular Component ]
           |                        |
           x               ( Target Action )  <-- BFS Root
                              /        \
                     [ Effect 1 ]    [ Reducer ]
                          |
                  [ Output Action ]

ngrx-graph models the state flow as a directed graph G=(V,E) where vertices are components, actions, effects, and reducers, and edges represent dispatch, handling, and processing relationships. When a user requests a focused graph for action A, the tool runs a bidirectional Breadth-First Search (BFS):

Forward traversal: Starting from A, follow edges to discover all actions triggered by effects that listen to A, then all actions triggered by effects listening to those actions, and so on. This captures the full downstream chain.

Backward traversal: Starting from A, follow edges in reverse to discover which components dispatch A and which effects produce A. This captures the upstream context.

The union of both traversals produces the minimal subgraph that contains A and everything reachable from or to it. Unreachable actions, effects, and reducers are excluded. The result is a focused, readable graph that shows exactly the flow relevant to the action under investigation.

The BFS implementation uses an adjacency list representation for O(V+E) traversal complexity. For projects with 500+ actions and 200+ effects, the focused graph typically contains 10 to 30 nodes, making it immediately readable.

Technical Stack and Architectural Decisions

Every library choice in ngrx-graph was made with a specific constraint in mind. Here is the reasoning behind each one:

ComponentChoiceWhyAlternative Considered
AST ParsingTypeScript Compiler API (ts.createSourceFile)Exact AST fidelity for TS/TSX. Handles generics, decorators, and type annotations that Babel strips or transforms. No configuration drift between parser and project tsconfig.Babel: faster but loses type information. Regex: breaks on nested callbacks and aliased imports.
Bounded Concurrencyp-limitPrevents CPU and memory exhaustion on codebases with thousands of files. max(1, CPU_COUNT - 2) workers keep the machine responsive during scans.Raw Promise.all: unbounded parallelism causes OOM on 10k+ file projects. Sequential: too slow for enterprise codebases.
CLI FrameworkoclifEnterprise-grade CLI structure: flag parsing, help generation, plugin system, testing utilities. Battle-tested at Salesforce.Commander/yargs: lighter but lacks built-in help formatting and test scaffolding.
Graph Rendering (primary)Native Graphviz dot binaryFastest rendering path. Produces publication-quality layouts. Available in most CI environments.N/A: this is the reference implementation.
Graph Rendering (fallback)@hpcc-js/wasm (viz.js)Zero-dependency WASM renderer. When dot is not installed (common in minimal Docker images or CI containers), --viz uses the in-browser Graphviz port. No installation required.Puppeteer/headless Chrome: heavy, slow, fragile in CI.
Output FormatGraphviz DOTStandard format supported by every graph tool. DOT files are text, diffable, and composable. SVG is a rendering step away.Custom JSON graph format: would require a custom renderer, losing interoperability.

The two-tier rendering strategy (native dot first, WASM fallback second) means ngrx-graph works everywhere without forcing users to install system packages. In CI, --viz gives you zero-dependency rendering. On a developer machine, the native binary is faster.

Real-World Examples and Edge Cases

The repository ships with four example cases demonstrating progressively complex scenarios.

Case 1: Basic Action Flow

A simple setup: one component dispatches action1, an effect listens for it and dispatches action2 and action3, and a reducer handles action3.

npx ngrx-graph action1

The generated graph shows the full flow: FirstComponent -> action1 -> effect1$ -> action2, action3 -> firstReducer. You see exactly which component started the chain and where it terminates. In enterprise codebases, this is the baseline: verifying that a feature’s action flow is complete end-to-end.

Focusing on action3 instead reveals a narrower slice: only nodes that affect or are affected by that specific action.

Case 2: Nested Actions

Actions can carry other actions in their payloads. Case 2 demonstrates nestedAction1 (containing action1 and action2 as payload actions) and nestedAction2. The graph distinguishes nested actions with a light cyan fill, making the composition pattern visually obvious.

This matters in large NgRx projects where action composition is a common pattern for orchestrating multi-step workflows. Without visual distinction, nested actions are easy to miss during code review.

Case 3: Reachability Filtering in Disconnected Graphs

When actions are disconnected (action2 exists but is not reachable from action1 through any effect chain), the focused graph correctly excludes it. Without reachability filtering, focused graphs would be noisier than useful. This is critical for enterprise codebases where hundreds of actions coexist but only a subset participate in any given flow.

The BFS algorithm ensures that the output is the minimal subgraph. No dangling nodes, no irrelevant edges. The difference between a focused graph and an aggregated graph in a 300-action project is the difference between a readable map and a wall of ink.

Case 4: Aliases and Re-exports

Real projects re-export actions through barrel files:

// index.ts
export { actionB, actionA as exportedActionA } from "./case4.actions";

Effects import the alias:

import { exportedActionA } from "./index";

ngrx-graph resolves these aliases back to the original action names, so the graph shows the canonical flow rather than breaking at re-export boundaries. In monorepo architectures with shared action libraries, alias resolution prevents the graph from fragmenting into disconnected subgraphs.

Edge Case: Conditional Dispatches

Effects sometimes dispatch actions conditionally:

loadUsers$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUsers),
    switchMap(() =>
      this.userService.getAll().pipe(
        map(users => users.length > 0
          ? loadUsersSuccess({ users })
          : loadUsersEmpty()
        )
      )
    )
  )
);

Both loadUsersSuccess and loadUsersEmpty appear as outputs. The scanner captures every possible dispatch path, ensuring no action is silently dropped from the graph.

Edge Case: Multi-Level Re-exports

In monorepos, actions may pass through multiple barrel files:

core/actions.ts  -->  core/index.ts  -->  feature/index.ts  -->  feature.effects.ts

The canonical symbol table resolves chains of aliases at any depth. The graph always shows the original action name regardless of how many re-export layers sit between the declaration and the usage.

Why a CLI Tool Outperforms Browser Visualizers

Several tools visualize NgRx state, but they serve different needs:

  • ngrx-visualizer (Google): A web-based tool for interactive exploration. Requires manual graph construction in a browser. Works well for small projects, less practical for CI/CD pipelines or large codebases.
  • ngrx-graph: A CLI-first tool designed for automation. Parses actual source code via AST, handles aliases and nested actions, supports focused graphs with reachability filtering, and outputs standard DOT/SVG files for integration into documentation pipelines.

The key differentiator is focused action graphs. Most visualization tools show the entire state graph at once. In a large project, that produces an unreadable web of nodes. ngrx-graph traces a single action through the full flow, showing only reachable nodes, which is where the debugging value lives.

Where ngrx-graph Pays Off in Production

Code review: Generate a focused graph for a new action to verify the full flow exists. A missing reducer handler or effect subscription becomes visually obvious.

Onboarding: New team members examine a project-wide graph to understand the state management architecture without reading every file. It is a living architecture diagram.

Debugging production issues: When a bug traces to stale state, generate a graph for the suspected action chain. You will quickly see if a reducer is missing a handler or if an effect dispatches an unexpected action.

Refactoring: Before renaming or removing an action, generate its focused graph to see every dependency. No surprise breakages.

Try It

npx ngrx-graph -d ./src --out ./out --svg

One command. One graph. Your NgRx store stops being a black box.

Links:

Contributions welcome. Open an issue if something breaks, or a pull request if you want to fix it.