RYAN WHEATLEY
Modular 
Architecture 
in 
React 
Applications 
Building a custom ESLint tool to enforce modular practices for scalable and maintainable React codebases. 
Illustration of Isolated Imports concept
Overview

When working on our first large-scale SaaS application in Next.js, we faced the challenge of managing a growing codebase with just two developers. We saw this as an opportunity to create a tool that would make the entire process more manageable and maintainable from the ground up.


This custom ESLint tool was born out of the need to avoid repeated discussions (or debates) about where things should be placed or what files should be named. By introducing clear guidelines and automated enforcement, it allowed us to focus on building features rather than arguing about structure. Much like Prettier for code formatting, this tool ensures consistency across the codebase. It may not align perfectly with every preference, but it eliminates ambiguity and standardizes practices. This consistency is invaluable, especially in small teams, saving time and creating a shared understanding of the project’s structure.


The rules enforced by this tool resemble a fractal pattern: no matter how large the codebase grows, the structure remains predictable and uniform at every level. This makes it easy for newcomers to onboard and navigate the project confidently.


Core Principles

This custom ESLint tool is built on a foundation of core principles that promote clean, maintainable, and scalable codebases. The structure it enforces ensures a modular and recursive approach to organizing components and features, allowing the application to grow naturally without unnecessary complexity. Here’s a detailed look at each principle:


1. Separation of Concerns

The project enforces a clear separation between reusable components and feature-specific modules. At the top level, the components/ directory contains reusable components that can be shared across the app, while the features/ directory encapsulates feature-specific logic and UI. This structure ensures that each feature remains self-contained, while reusable components are centralized and easy to maintain.


Inside the components/ directory, only an index.ts file is allowed for exporting reusable components. Individual component files or directories (explained below) are located at the same level and are imported into the index.ts file for reuse.


2. The Component Directory

A component directory represents a reusable React component that is composed of other unique components or requires its own encapsulated logic. The structure of a component directory is recursive, supporting nested components and modular functionality:


  • The directory name matches the name of the component (e.g.,ColorChart).
  • It contains an index.ts file for exporting the component, and can include other files such as ColorOptions.tsx for specific sub-components, and directories like components/,hooks/, utils/, and tests/.
  • Any sub-components, hooks, or utilities within the directory can only be imported inside that directory, keeping the component's functionality and structure encapsulated.
  • Reusable sub-components that are shared across multiple directories or features are moved to higher-level directories in components/.

For example, a ColorChart component directory may have:

components/
  ├── ColorChart/
  │   ├── index.ts
  │   ├── ColorOptions.tsx
  │   ├── components/
  │   │   └── ColorPicker.tsx
  │   │   └── ColorWheel/
  |   │       ├── index.ts
|       ├── components/
  |   |       | ...
  │   ├── hooks/
  │   │   └── useColorSelection.ts
  │   ├── utils/
  │   │   └── calculateColors.ts
  │   └── tests/
  │       └── ColorChart.test.ts

3. Features and Their Relationship with Components

The features/ directory is designed to house feature-specific components and logic. Features may include components unique to their functionality, as well as logic such as hooks, utilities, and state management specific to that feature. Components in features/ can only be shared outside the feature by exporting them explicitly from the feature’s index.ts file. This prevents accidental dependencies and maintains modularity.


If a small component originally made for one feature is required in another feature, such as an avatar component from features/Share being used in features/Export, the component must first be moved to the components/ directory to reflect its reusable nature. Attempting to import a component directly from one feature into another will result in an ESLint error.


This approach ensures that as the app grows, the structure evolves naturally without confusion about where components should reside. It provides a clear path for promoting components to higher levels of reusability as their usage expands.


4. Modular and Recursive Structure

The component and feature directories follow a recursive and modular design. A component can encapsulate sub-components, and features can encapsulate components. This hierarchy allows for logical grouping and reusability at all levels, ensuring a predictable and maintainable project structure.


A feature component cannot be imported into the top-level components directory.

Components in a feature must be exported explicitly from the feature's index.ts file.

Implementation

This custom ESLint rule is implemented using a carefully designed configuration. It ensures that large-scale React projects maintain a modular and consistent structure. Here’s a detailed breakdown of how this rule is structured and enforced:


Restricting Direct Subdirectory Imports

The rule disallows importing files directly from subdirectories unless explicitly re-exported in an index.ts file. This enforces encapsulation and ensures a clear structure for navigating dependencies. For example:


// Allowed:
import LoginForm from 'features/auth/components';

// Disallowed:
import LoginForm from 'features/auth/components/LoginForm/LoginForm.tsx';

Handling Path Aliases

Modern React projects often use TypeScript path aliases to simplify imports. This rule supports these aliases and applies the same restrictions to them. For example, aliases like @components or @features are mapped and validated to ensure consistency.


// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@components/*": ["components/*"],
      "@features/*": ["features/*"]
    }
  }
}

Core Validation: splitImportPathAtDivergence, calculateDivergenceLength, and checkForInvalidDivergence

A key part of enforcing the rule is determining the divergence between the import path and the file path. This process is powered by three tightly integrated functions:


// splitImportPathAtDivergence isolates the differing path segments:
export default function splitImportPathAtDivergence(
  importPath: string, 
  filePath: string
): string[] {
  const importPathSegments = importPath.split("/");
  const filePathSegments = filePath.split("/");

  let divergenceIndex = 0;
  while (divergenceIndex < Math.min(importPathSegments.length, filePathSegments.length)
    && importPathSegments[divergenceIndex] === filePathSegments[divergenceIndex]) {
    divergenceIndex++;
  }
  return importPathSegments.slice(divergenceIndex);
}

calculateDivergenceLength builds upon this by returning the numeric depth of divergence. This value is then used by checkForInvalidDivergence, which orchestrates several checks to validate whether the import is allowed.


// checkForInvalidDivergence performs comprehensive validation:
export default function checkForInvalidDivergence(
  importPath: string, 
  filePath: string, 
  options: Required<TRuleOptions>
): string | undefined {
          
  const divergenceLength = calculateDivergenceLength(importPath, filePath);
  const isSameComponent = checkIfSameComponent(importPath, filePath);

  if (isSameComponent) {
    return; // Valid import
  }
  if (divergenceLength > 2) {
    return generateErrorMessage(ErrorMessages.INVALID_IMPORT_DEPTH);
  }
}

Together, these functions validate import paths, check for invalid index imports, and ensure modularity is enforced across the codebase. This trio was instrumental in making the rule both robust and flexible for various project configurations.


The rule is integrated with ESLint using the eslint-plugin-local-rules package. This allows for custom logic to be applied during linting. For instance, the rule checks whether imports adhere to the index.ts restriction or if aliases are resolved correctly.


Challenges

While this custom ESLint rule offers a cleaner and more maintainable project structure, adopting it comes with its own set of challenges. A common issue arises when using client components with Next.js. Components that require the "use client" directive must be properly identified and annotated. Failing to do so can lead to unexpected runtime errors, especially when server components attempt to import unmarked client components. Misconfigured path aliases in TypeScript can also break imports and cause ESLint rules to fail. Ensuring that aliases are correctly defined in both tsconfig.json and ESLint configurations is critical. Additionally, using absolute paths without proper alias mappings can lead to inconsistent behavior.


Introducing this rule into an existing project can require significant refactoring. This includes reorganizing directories, updating import paths, and resolving circular dependencies that were previously overlooked. While this upfront effort pays off in the long term, it can be a daunting task for large, legacy codebases. At the same time, strict rules ensure modularity and maintainability but can sometimes conflict with developers’ needs for flexibility. Finding a balance between enforcing rules and allowing exceptions for specific use cases is important. Configurable linting rules and well-documented exceptions can help mitigate this challenge.


Testing

In order to ensure that this custom ESLint rule was properly enforced across the project, we adopted a test-driven development (TDD) approach. Given the wide variety of edge cases involved in validating imports, it was crucial to cover all possible scenarios to ensure robustness and reliability.


We designed a comprehensive suite of tests to validate the behavior of the core logic, particularly the calculateDivergenceLength function, which is key to enforcing the import rules. The tests focused on various import scenarios, such as imports within the same feature, across features, and between components, as well as cases involving deeply nested structures or invalid imports. Some of the key test cases were:


Imports within the same feature at different levels: We ensured that the calculateDivergenceLength correctly calculates the distance between files, whether they reside at the same level or at different nesting depths.

// Import paths: inside the same feature, at different levels
expect(calculateDivergenceLength(
  "features/Feature1/utils/helper1.ts", 
  "features/Feature1/components/Component1/components/Component2.ts"
)).toBe(2);

Cross-feature imports: The function was tested to correctly calculate the divergence length when importing from one feature to another. This ensured that the modularization strategy was respected, preventing unintentional imports between unrelated features.

// Import paths: between features
expect(calculateDivergenceLength(
  "features/Feature1/components/Component1", 
  "features/Feature2/components/Component2/components/Component2.tsx"
)).toBe(3);

Deeply nested imports: We tested cases where components or utilities are deeply nested within a feature, making sure the function accurately calculates the correct distance.

// Import paths: deeply nested components within the same feature
expect(calculateDivergenceLength(
  "features/Feature1/components/Component1/utils/helper1.ts", 
  "features/Feature1/components/Component1/components/Component2/components/Component3.tsx"
)).toBe(2);

Invalid imports: We included test cases for imports that violate the modularity rules, such as importing from a location not re-exported via the index file or trying to import across components or features incorrectly.

// Importing a hook into a component that is not exported via index file
expect(calculateDivergenceLength(
  "features/Feature1/components/Component1/hooks/useHook1.ts", 
  "features/Feature1/components/Component2/components/Component2.ts"
)).toBe(3);

These tests helped us identify potential issues early, ensuring that the rule would work seamlessly across a variety of use cases and configurations. Additionally, by using TDD, we were able to refine our ESLint rule incrementally, building confidence in its correctness and reliability as we developed.


This custom ESLint rule enforces a clean, modular architecture in React applications, ensuring maintainability and scalability. By defining strict import rules and promoting best practices, it simplifies project management and prevents common pitfalls in large codebases.


RYAN
WHEATLEY
Delivering bespoke web applications with a creative edge.