joshx
WorkWritingResume
joshx
© 2026
Writing
July 25, 20265 min read

From React Files to Architecture: Building Arclens [Part 1]

React applications have a way of becoming complicated faster than you expect.

A component imports another component. That component depends on a third component. That third component depends on a hook. The hook calls a service, which depends on another service, which depends on several utils. Before long, understanding how one part of the application connects to another means jumping through files, folders, and abstractions.

At some point, you stop asking “Where is this used?” and start asking: “How is all of this actually connected?”

Where is this component used

That question is what led me to build Arclens.

The core idea

What if you could look at a React codebase and see its architecture instead of opening hundreds of files?

That became the basic idea behind Arclens.

Parse the codebase. Understand how its files depend on each other. Turn those relationships into a graph. Make the graph interactive. If you are not familiar with parsing, go through this article. They did a good job explaining parsing in TypeScript.

The first part of that problem was understanding the source code itself.

Rather than executing the application, I wanted Arclens to analyze the code statically. That meant working with the TypeScript AST. The TypeScript AST is not something straightforward. It is one of the more cumbersome concepts I have had to work with. Don't take my word for it. Here is the JSON tree for just console.log("Hello world") expression:

  • ▶
    {} 5 keys
    • "Program"
    • 0
    • 205
    • "module"
    • ▶
      [] 1 item
      • ▶
        {} 4 keys
        • "ExpressionStatement"
        • 179
        • 205
        • ▶
          {} 6 keys
          • "CallExpression"
          • 179
          • 205
          • false
          • ▶
            {} 7 keys
            • "MemberExpression"
            • 179
            • 190
            • false
            • false
            • ▶
              {} 4 keys
              • "Identifier"
              • 179
              • 186
              • "console"
            • ▶
              {} 4 keys
              • "Identifier"
              • 187
              • 190
              • "log"
          • ▶
            [] 1 item
            • ▶
              {} 4 keys
              • "Literal"
              • 191
              • 204
              • "Hello world"

Now imagine a codebase with hundreds or thousands of components. That is what happens under the hood when TypeScript parses a file: it reads the source text, constructs a syntax tree with every token classified by kind, and you can walk or query that tree however you want.

To work with the tree I ended up using ts-morph, which provides a higher-level API around the TypeScript compiler API. It makes it much easier to inspect source files, imports, exports, declarations, and other relationships in a TypeScript project.

Arclens interactive dependency flow

At a high level, the pipeline looks like this:

Source files → AST → dependency extraction → graph construction → layout → visualization

The approach

Instead of trying to understand the application at runtime, Arclens starts with the source files themselves.

For each file, it inspects the import declarations and uses that information to build the dependency graph.

A simplified version of the idea looks like this:

const project = new Project();
const sourceFile = project.addSourceFileAtPath(filePath);

const imports = sourceFile.getImportDeclarations();
for (const declaration of imports) {
  const moduleSpecifier = declaration.getModuleSpecifierValue();

  // Resolve the imported module and create the graph relationship
}

The useful part of ts-morph is that I don't have to manually traverse TypeScript SyntaxKind nodes and figure out every node myself. I can work with SourceFile objects and their declarations at a much higher level.

That makes questions like these answerable programmatically: What does this file import?, What does this barrel file re-export?, Which parts of the codebase depend on this module?

Once I had those relationships, the next problem was turning them into something useful.

Extracting imports

Extracting imports is the foundation of the whole pipeline. The actual implementation looks like this:

export function extractImportEdges(sourceFile: SourceFile) {
  return sourceFile.getImportDeclarations().map((declaration) => ({
    from: sourceFile.getFilePath(),
    to: declaration.getModuleSpecifierValue(),
    defaultImport: declaration.getDefaultImport()?.getText(),
    namedImports: declaration.getNamedImports().map((n) => n.getName()),
    line: declaration.getStartLineNumber(),
    isTypeOnly: declaration.isTypeOnly(),
    resolvedTo: declaration
      .getModuleSpecifierSourceFile()
      ?.getFilePath(),
  });
}

Each source file produces an array of import edges. Every edge captures where the import comes from, what module specifier it targets, which symbols are imported (default vs. named), whether it is a type-only import, and the resolved file path on disk.

The key method here is getModuleSpecifierSourceFile(). This is where ts-morph uses the TypeScript program's module resolution to follow the specifier string ("../components/Input") and resolve it to an actual file path. This handles path aliases, index.ts barrel files, and extension resolution. Without it, I would have had to reimplement TypeScript's module resolution algorithm myself.

For each file analyzed, extractImportEdges runs and its output gets collected into a flat array of edges. Those edges are then merged across all files and passed into buildGraph, which turns them into actual graph nodes and edges.

When resolution breaks down

The real problems started when I tried to resolve what those imports actually meant at the graph level.

If Button.tsx imports ../components/Input, which file does that point to? What happens when the import goes through a barrel file? What about an index.ts that re-exports several modules? And what happens when the graph contains hundreds of files?

buildGraph has to walk every import edge, look up the exporting file, match default imports against default exports and named imports against named exports, and create the right graph edge. A named import like { formatDate } might match an export in the resolved file, or it might be a re-export from somewhere else entirely.

for (const imp of importEdges) {
  if (!imp.resolvedTo || imp.resolvedTo.includes("node_modules")) {
    continue;
  }

  const fromExport = resolveFromExport(exports, imp.from);
  ensureNode(nodes, fromExport);

  if (imp.defaultImport) {
    const toExport =
      findDefaultExportInFile(exports, imp.resolvedTo) ??
      findExportInFile(exports, imp.resolvedTo, imp.defaultImport);

    if (toExport) {
      graphEdges.push({
        from: nodeId(fromExport),
        to: nodeId(toExport),
        type: "imports",
      });
    }
  }

  for (const symbol of imp.namedImports) {
    const toExport =
      findExportInFile(exports, imp.resolvedTo, symbol) ??
      findExportByName(exports, symbol);

    if (toExport) {
      graphEdges.push({
        from: nodeId(fromExport),
        to: nodeId(toExport),
        type: "imports",
      });
    }
  }
}

The graph also captures two other relationship types beyond imports: renders (JSX usage like <Button />) and uses (hook calls like useAuth()). These are extracted from the AST using getDescendantsOfKind(SyntaxKind.JsxOpeningElement) and getDescendantsOfKind(SyntaxKind.CallExpression) respectively. The three edge types together give a much fuller picture of how components actually interact.

Suddenly, the problem wasn't just AST analysis anymore. It became a combination of dependency resolution, graph construction, and information design.

In the next part of this article, I explained how I layed out the graph and designed the viewer.

PreviousReact Testing with Testing LibraryNextFrom React Files to Architecture: Building Arclens [Part 2]
All articles