Skip to content

Instantly share code, notes, and snippets.

@ceaksan
Created January 14, 2026 15:18
Show Gist options
  • Select an option

  • Save ceaksan/6d7801b8bc46cebed9d5333e9e634f08 to your computer and use it in GitHub Desktop.

Select an option

Save ceaksan/6d7801b8bc46cebed9d5333e9e634f08 to your computer and use it in GitHub Desktop.

Circular Dependency Guide

Version: 2.0.0 Scope: ESM, Vite, React, Architecture Status: Authoritative Reference

This guide provides a comprehensive overview of "Circular Dependencies" in modern JavaScript ecosystems, specifically focusing on native ESM environments like Vite. It explains why they happen, why they are fatal in ESM (unlike CommonJS), and how to resolve them architecturally.


1. Executive Summary

In modern web development, particularly with Vite and Native ESM, you may encounter the following runtime error:

ReferenceError: Cannot access 'X' before initialization

This error is not a bug in Vite or React. It is a fundamental consequence of the ECMAScript Module (ESM) specification. It occurs when a module attempts to access an export from another module that hasn't finished initializing yet—usually because that second module is waiting for the first one to finish (a loop).


2. The Golden Rule

A circular dependency is almost always an architectural flaw, not a tooling bug.

Do not try to "hack" your way around it with setTimeout or lazy imports unless absolutely necessary. If you have a cycle, your dependency graph is tangled. Untangle it.


3. Anatomy of a Cycle

The Loop

Imagine two files, Module A and Module B.

  1. Module A imports Module B.
  2. Module A starts executing.
  3. Module A hits the import statement for Module B and pauses to load it.
  4. Module B starts executing.
  5. Module B imports Module A.

CRITICAL MOMENT: In CommonJS (Node.js legacy), Module A might return a partial object. In ESM (Vite/Browser), Module A is in the "Evaluating" state but essentially empty/uninitialized.

If Module B tries to read or execute anything from Module A immediately (at the top level), it will crash with ReferenceError: Cannot access 'X' before initialization.


4. Common Culprits

4.1. The "Utils" Sinkhole

This is the most common cause. You have a massive utils/index.js or helpers.js.

The Cycle:

  1. utils.js exports formatDate and calculateTax.
  2. calculateTax needs a constant from constants.js.
  3. constants.js imports formatDate from utils.js to format a default string.
  4. Result: utils -> constants -> utils.

Solution: Split utils.js into atomic files (date.js, tax.js) or ensure low-level modules (like constants) never import from high-level modules.

4.2. The Barrel File Trap (index.ts)

Barrel files make imports cleaner but obscure relationships.

The Cycle: In features/auth/index.ts:

export * from './AuthContext';
export * from './useAuth';

In AuthContext.tsx:

import { useAuth } from '.'; // Imports from index, which imports AuthContext

Solution: Internal files within a module should import siblings directly, not via the parent index.

  • Bad: import { useAuth } from '.';
  • Good: import { useAuth } from './useAuth';

4.3. State Management (Store <-> Component)

Often seen in Redux, Zustand, or Context.

  • Store imports a Component (e.g., to mount a modal inside an action, or for TS types).
  • Component imports the Store to get state.

Solution:

  • Move types to types.ts.
  • Pass UI components as props or use dependency injection via a manager.
  • Keep business logic separate from UI rendering.

5. Prevention & Resolution Patterns

Pattern A: The "Shared Core" (Extraction)

If A needs B and B needs A, they usually both need a hidden C.

Before (Cycle): User.ts (imports Permission) <---> Permission.ts (imports User)

After (DAG - Directed Acyclic Graph): User.ts ---> SharedTypes.ts <--- Permission.ts

Extract the shared data structures, interfaces, or constants into a leaf node that imports nothing.

Pattern B: Dependency Injection

Don't import the dependency statically; accept it as an argument.

Bad:

// fileA.js
import { doSomething } from './fileB';
export const init = () => doSomething();

Good:

// fileA.js
export const init = (callback) => callback();

Pattern C: Function Hoisting / Getters

Delay the access until runtime, not load time.

Bad:

// A.js
import { B_VALUE } from './B';
export const A_VALUE = B_VALUE + 1; // Crashes if B is not ready

Good:

// A.js
import { B_VALUE } from './B';
export const getAValue = () => B_VALUE + 1; // Safe, runs when called

6. TypeScript Specifics

TypeScript can often cause "accidental" circular dependencies when you only wanted a Type.

The Fix: Use import type.

Bad:

// Child.tsx
import { Parent } from './Parent'; // Runtime dependency!
export interface ChildProps {
  parent: Parent;
}

Good:

// Child.tsx
import type { Parent } from './Parent'; // Erased at runtime
export interface ChildProps {
  parent: Parent;
}

Using import type tells the compiler: "I only need this for static analysis. Do not emit a require or import statement in the final JavaScript." This effectively breaks the runtime cycle.


7. Soft vs. Hard Cycles

Not all cycles crash your app immediately, which makes them dangerous.

Hard Cycle (Immediate Crash)

Occurs when modules use each other's exports at the top level.

  • Symptom: White screen, ReferenceError on startup.
  • Priority: Critical Fix.

Soft Cycle (Time Bomb)

Occurs when modules use each other's exports inside functions that run later.

  • Symptom: App loads fine, but might crash when a specific button is clicked or a specific route is visited.
  • Risk: Passes CI/CD, passes initial manual test, crashes in production for edge cases.

Warning: Even "Safe" soft cycles confuse bundlers (causing code bloat) and can turn into Hard cycles with minor refactors. Treat all cycles as defects.


8. The Firefighter's Guide (Debugging)

When you see the white screen of death, follow this protocol.

Step 1: Isolate the Cycle

If the error is Cannot access 'UserContext' before initialization, you know UserContext is part of a loop.

Step 2: Enable Vite Debug

Run your dev server with debug logs to see the transformation order.

DEBUG=vite:transform npm run dev

Watch the terminal. Look for the file that was loading just before the error occurred.

Step 3: Use Madge

Use madge to visualize the specific file's dependencies.

npx madge --circular src/features/user/UserContext.tsx

Step 4: The "Comment Out" Test

Go to one of the files in the suspected loop and comment out imports one by one until the app starts (even if it's broken). This confirms the guilty link.


9. Code Review Checklist

Reviewers: Reject PRs that contain these smells.

  • Sibling Barrier: Does FileA.tsx import FileB.tsx using import ... from '.'? (Forcing a go-through parent index).
  • Type Imports: Are proper class/interface imports using import type?
  • Utils Bloat: Is utils.js importing a domain-specific file (e.g., utils imports auth-helper)?
  • Cross-Domain Leak: Does a low-level component (UI) import a high-level store (State)?
  • Config Logic: Does a config file import a helper function? Configs should be pure JSON/literals if possible.

10. CI/CD & Prevention

Don't rely on human diligence. Automate it.

10.1. Add Madge to package.json

"scripts": {
  "check:circular": "madge --circular --extensions ts,tsx,js,jsx src/"
}

10.2. Block Builds on Cycles

In your CI pipeline (GitHub Actions, GitLab CI), run this command. If it finds cycles, it exits with non-zero code, failing the build.

# In your workflow
npm run check:circular

This ensures no new circular dependency can ever merge to main.


11. Vite vs. Webpack

Why did this work in my old Create React App (Webpack)?

  • Webpack bundles everything before running. It can often "patch" circular references by resolving exports later. It's forgiving but unsafe.
  • Vite uses Native ESM in development. The browser respects the strict spec. If the variable isn't ready, the browser throws immediately.

This is a feature. Vite forces you to write cleaner architecture that won't break unpredictably in production or future environments.


12. Final Thought

"If you have to trick the compiler, you are delaying a bug."

Fix dependencies by moving shared state down the tree, not by hacking the import order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment