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.
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).
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.
Imagine two files, Module A and Module B.
- Module A imports Module B.
- Module A starts executing.
- Module A hits the import statement for Module B and pauses to load it.
- Module B starts executing.
- 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.
This is the most common cause. You have a massive utils/index.js or helpers.js.
The Cycle:
utils.jsexportsformatDateandcalculateTax.calculateTaxneeds a constant fromconstants.js.constants.jsimportsformatDatefromutils.jsto format a default string.- 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.
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 AuthContextSolution: Internal files within a module should import siblings directly, not via the parent index.
- Bad:
import { useAuth } from '.'; - Good:
import { useAuth } from './useAuth';
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.
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.
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();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 readyGood:
// A.js
import { B_VALUE } from './B';
export const getAValue = () => B_VALUE + 1; // Safe, runs when calledTypeScript 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.
Not all cycles crash your app immediately, which makes them dangerous.
Occurs when modules use each other's exports at the top level.
- Symptom: White screen,
ReferenceErroron startup. - Priority: Critical Fix.
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.
When you see the white screen of death, follow this protocol.
If the error is Cannot access 'UserContext' before initialization, you know UserContext is part of a loop.
Run your dev server with debug logs to see the transformation order.
DEBUG=vite:transform npm run devWatch the terminal. Look for the file that was loading just before the error occurred.
Use madge to visualize the specific file's dependencies.
npx madge --circular src/features/user/UserContext.tsxGo 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.
Reviewers: Reject PRs that contain these smells.
- Sibling Barrier: Does
FileA.tsximportFileB.tsxusingimport ... from '.'? (Forcing a go-through parent index). - Type Imports: Are proper class/interface imports using
import type? - Utils Bloat: Is
utils.jsimporting a domain-specific file (e.g.,utilsimportsauth-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.
Don't rely on human diligence. Automate it.
"scripts": {
"check:circular": "madge --circular --extensions ts,tsx,js,jsx src/"
}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:circularThis ensures no new circular dependency can ever merge to main.
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.
"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.