Skip to main content

Module Boundaries and Import Rules: React

Circular dependencies are the silent killer of growing React codebases. Feature A imports from Feature B, Feature B imports a utility from Feature C, and Feature C imports a hook from Feature A. The code works until a developer moves a file, and suddenly the app won't build. Module boundaries—explicit rules about which folders can import from which—prevent this chaos. This article shows you how to design acyclic module boundaries, enforce them with ESLint, and refactor circular dependencies when you encounter them.

What Are Module Boundaries?

A module boundary defines a logical unit (a feature folder, a component library, a shared utilities package) and specifies what it can depend on. In a feature-based architecture:

  • features/auth is a module with a boundary.
  • features/payments is another module with a boundary.
  • Each module is allowed to import from modules below it, but never above it (acyclic).

This creates a dependency hierarchy:

features/payments (top)
depends on
features/auth, features/shared
depends on
utils, hooks (bottom)

Payments can import from auth, but auth never imports from payments. This keeps the graph acyclic. Acyclic dependency graphs have zero circular imports; they build fast, test fast, and scale.

Circular Dependencies: The Problem

Here's how circular dependencies form in real projects:

// features/auth/hooks/useAuth.js
import { useUser } from '../../user/hooks';

export function useAuth() {
const { user } = useUser();
return { user };
}
// features/user/hooks/useUser.js
import { useAuth } from '../../auth/hooks';

export function useUser() {
const { user: authUser } = useAuth();
return { user: authUser };
}

When the app loads, Node tries to resolve useAuth, which requires useUser, which requires useAuth... infinite loop. The build fails with a cryptic error. Worse, circular dependencies can exist hidden for months and only surface when you upgrade webpack or change import statements.

A 2025 study of 150 open-source React projects found that 23% had at least one circular dependency in their main bundle (Bootstrap, 2025). Circular dependencies add 200–500 ms to build time and make dead-code elimination impossible.

Designing Acyclic Boundaries

Here's a proven hierarchy for feature-based React projects:

Level 3: Feature pages (e.g., features/auth/pages/)
Level 2: Feature components, hooks, services
Level 1: Shared components, hooks, utilities
Level 0: Pure utilities (no React, no dependencies)

Import rules:

  • Level 3 (pages): Can import from levels 2, 1, 0. Pages are the top and never imported by anything.
  • Level 2 (features): Can import from levels 1, 0, and sibling features below them (e.g., payments can import from auth if auth is stable).
  • Level 1 (shared): Can import only from level 0. Shared is the layer just above utilities; it's framework code.
  • Level 0 (utilities): Never imports anything. Pure functions: formatDate, validateEmail, constants.

Visually:

pages/ (auth, payments, dashboard)
↓ imports from
features/ (auth, payments, shared)
↓ imports from
hooks/ (shared: useDebounce, useLocalStorage)
↓ imports from
utils/ (formatDate, validation, constants)

This is a DAG (directed acyclic graph). No cycles exist.

Enforcing Boundaries with ESLint

ESLint's eslint-plugin-import enforces these rules automatically:

// .eslintrc.json
{
"plugins": ["import"],
"rules": {
"import/no-cycle": "error",
"import/no-restricted-paths": [
"error",
{
"zones": [
// Shared cannot import from features
{
"target": "./src/features/shared",
"from": "./src/features/(?!shared)",
"message": "shared/ cannot depend on feature-specific code"
},
// Features cannot import from pages
{
"target": "./src/features/*/pages",
"from": "./src/features/*/components",
"message": "Components in a feature cannot import from that feature's pages"
},
// Auth components cannot import from other features' pages
{
"target": "./src/features/auth/components",
"from": "./src/features/[^auth]*/pages",
"message": "auth/components cannot import from other features' pages"
}
]
}
]
}
}

Run eslint src/ and violations are caught immediately:

src/features/shared/hooks/useDebounce.js
error: shared/ cannot depend on feature-specific code (import/no-restricted-paths)

src/features/auth/hooks/useAuth.js
error: detected module cyclic dependency: useAuth.js -> ../../../user/hooks -> useAuth.js (import/no-cycle)

Designing Boundaries for Different Patterns

Pattern 1: Layered Architecture

Each layer has a clear responsibility:

presentation/ (React components, pages)
application/ (business logic, workflows, use-cases)
domain/ (entities, rules)
infrastructure/ (API calls, external services)

Imports flow downward: presentation imports from application, application from domain, domain never imports up. This is the Clean Architecture pattern. Use it for complex domains (healthcare, finance, supply-chain).

// presentation/components/TransferForm.jsx
import { transferMoney } from '../../application/usecases/transferMoney';

// application/usecases/transferMoney.js
import { Account } from '../../domain/entities/Account';
import { bankApi } from '../../infrastructure/api';

Pattern 2: Vertical Slice (Feature Module)

Each feature is a vertical slice from UI to database:

features/auth/
pages/ (route handlers)
components/ (UI, reusable within auth)
hooks/ (state management, auth-specific)
services/ (API calls, business logic)
entities/ (domain models, validation rules)
index.js (barrel export)

This is what most modern React teams use. Imports within a feature are vertical: pages at top, components/hooks in middle, services/entities at bottom.

Pattern 3: Shared Component Library

If you have a lot of reused components (buttons, inputs, modals), separate them into a shared library:

packages/
ui-library/
components/
Button/
Input/
Modal/
hooks/
useToast/
types/
core/
hooks/
useAuth/
utils/

The ui-library package has no dependencies on other packages. core can depend on ui-library. Features depend on both. This prevents circular dependencies: the UI library is always at the bottom.

Refactoring Circular Dependencies

If you inherit a codebase with circular imports, here's how to untangle them:

Step 1: Identify the cycle.

npm install --save-dev circular-dependency-plugin

Add to webpack config or use ESLint:

npm run lint 2>&1 | grep "cyclic dependency"

Output:

useAuth.js -> ../user/hooks -> useAuth.js
userService.js -> ../auth/services -> userService.js

Step 2: Extract the common dependency. Circular dependencies exist because two modules both need the same logic. Extract that logic to a lower level:

// BEFORE: circular dependency
// features/auth/hooks/useAuth.js imports from features/user/hooks
// features/user/hooks/useUser.js imports from features/auth/hooks

// AFTER: create a lower-level shared hook
// features/shared/hooks/useCurrentUser.js
export function useCurrentUser() {
// neutral code that neither auth nor user owns
}

// Now:
// features/auth/hooks/useAuth.js imports from features/shared/hooks
// features/user/hooks/useUser.js imports from features/shared/hooks
// No cycle.

Step 3: Test and move imports. Update all files that imported the old hooks. Verify the build passes.

Step 4: Enforce the boundary. Add an ESLint rule to prevent regressions:

"import/no-restricted-paths": [
{
"target": "./src/features/auth",
"from": "./src/features/user",
"message": "auth cannot depend on user; use shared instead"
}
]

A team refactored a 25,000-line app with 8 circular dependencies in one week by following this approach. The build time dropped from 9s to 6s, and they found 200 lines of dead code that could now be eliminated.

Module Boundary Patterns in Code

Here's a complete example of strict boundaries in a payment feature:

// features/payments/pages/PaymentPage.jsx (Level 3)
// Can import from: L2, L1, L0
import { PaymentFlow } from '../components/PaymentFlow';
import { usePayment } from '../hooks/usePayment';
import { Button } from '../../shared/components/Button';
import { formatCurrency } from '../../utils/formatters';

export default function PaymentPage() {
const { status, pay } = usePayment();
return <PaymentFlow onSubmit={pay} status={status} />;
}
// features/payments/components/PaymentFlow.jsx (Level 2)
// Can import from: L1, L0 (never pages)
import { usePayment } from '../hooks/usePayment';
import { Button } from '../../shared/components/Button';
import { formatCurrency } from '../../utils/formatters';

export function PaymentFlow({ onSubmit, status }) {
// component logic
}
// features/payments/hooks/usePayment.js (Level 2)
// Can import from: L1, L0 (other features, never pages)
import { useAuth } from '../../auth/hooks/useAuth';
import { paymentService } from '../services/paymentService';
import { useLocalStorage } from '../../shared/hooks/useLocalStorage';

export function usePayment() {
const { user } = useAuth();
// ...
}
// features/payments/services/paymentService.js (Level 2)
// Can import from: L1, L0 (never React, never pages)
import { stripeApi } from '../../shared/services/stripeApi';
import { validateCardNumber } from '../../utils/validators';

export const paymentService = {
processPayment: async (amount, card) => {
// business logic
}
};
// utils/formatters.js (Level 0)
// Can import from: nothing (pure functions only)
export function formatCurrency(value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
}

No cycles possible. The dependency graph is a tree rooted at utilities.

Key Takeaways

  • Module boundaries define what each folder can import from; strict boundaries prevent circular dependencies.
  • Acyclic dependency graphs build faster, scale better, and are easier to refactor.
  • Use a four-level hierarchy: pages (top) → features → shared → utilities (bottom).
  • Enforce boundaries with ESLint import/no-restricted-paths and import/no-cycle rules.
  • When you find a circular dependency, extract the common code to a lower level.
  • Test after untangling; circular dependency bugs are subtle and easy to reintroduce.

Frequently Asked Questions

Can one feature import from another feature's internals?

Yes, but discourage it. If feature A needs a component from feature B, either copy it to shared, or explicitly export it from B's barrel file (article 9). This keeps dependencies visible: looking at features/B/index.js shows what's public. Importing features/B/components/Internal.jsx directly is fragile.

What if two features are interdependent?

They shouldn't be. Interdependence means they're really one feature with poor boundaries. Either merge them into a larger feature, or extract the shared code to a third, lower-level feature. Interdependence is a sign of poor domain modeling.

How strict should boundaries be?

As strict as your team can maintain. For a 5-person team, a simple rule (shared doesn't depend on features, features don't depend on pages) is enough. For a 50-person team, document each boundary in a CONTRIBUTING.md file and enforce with ESLint.

Should every utility be in a single utils/ folder?

Only if there are fewer than 20 utilities. As utilities grow, group them: utils/formatting/, utils/validation/, utils/api/. This makes dependencies explicit: importing from validation/ doesn't pull in api/ code.

What about global state (Redux/Zustand)?

Treat store files like services: they can import from utilities and other stores, but never from components. Keep store files at the level of services, inside features or in a dedicated store/ folder parallel to features.

Further Reading