Command Palette

Search for a command to run...

Hectal
PHASE 22Advanced ~27 min· topic 1 of 7

Topic 22.1

Project Structure That Scales

In one line

Group files by feature (everything about the cart in features/cart/) instead of by file type, keep each thing next to the code that uses it, put truly generic UI in a shared folder, give each feature one public entry point, and let a lint rule enforce who may import what.

Think of it like this

A kitchen. You could sort it by type of object: all metal things in one cupboard, all glass things in another, all wooden things in a third. To make tea you'd visit every cupboard. Most kitchens are sorted by job instead: the tea, cups, sugar and kettle sit together near the tap. A React project works the same way. Sorting files by type (components/, hooks/, utils/) feels tidy on day one; sorting them by feature (cart/, checkout/, search/) is what keeps you fast when the app is big.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Type-based structure
Folders named after kinds of file: components/, hooks/, utils/.
Feature-based structure
Folders named after product areas: features/cart/, features/search/, each holding all its own files.
Colocation
Keeping a file (a test, a style, a hook) right next to the code that uses it.
Shared UI
Generic components such as Button and Dialog that any feature may use and that know nothing about features.
Public API (of a folder)
The few exports other code is allowed to use, usually listed in the folder's index.js.
Barrel file
An index.js that only re-exports things from other files: export * from "./Button";.
Tree shaking
A bundler removing code that is imported but never used, when it can prove removing it is safe.
Import boundary
A rule about which folders may import from which, ideally enforced by a lint rule.
Path alias
A short import prefix such as @/ that points at src/, so imports don't need ../../../.

Step by step

01Before: sorted by file type

This shop has 40 components. To change how the cart works you open components/, hooks/, api/, utils/ and styles/, and you can't tell which of the 30 hooks are still used.

src (before)whole filebash
src/
  components/   CartSummary.jsx  ProductCard.jsx  CheckoutForm.jsx  Button.jsx  ... 36 more
  hooks/        useCart.js  useProducts.js  useCheckout.js  useDebounce.js  ... 26 more
  api/          cartApi.js  productsApi.js  ordersApi.js
  utils/        formatLine.js  formatPrice.js  validateCard.js
  styles/       cart.css  checkout.css  button.css

02After: sorted by feature, shared code apart

Each feature owns its folder. Tests and styles sit beside the component they test or style. shared/ holds only generic pieces, and app/ wires features together (routes, providers).

src (after)whole filebash
src/
  app/                    App.jsx  routes.jsx  providers.jsx
  features/
    cart/
      index.js            <- public API: CartProvider, CartSummary, useCart
      CartSummary.jsx
      CartSummary.test.jsx
      CartSummary.module.css
      CartContext.jsx
      useCart.js
      cartApi.js
      formatLine.js       <- private helper
    catalog/
      index.js
      ProductCard.jsx
      useProducts.js
    checkout/
      index.js
      CheckoutPage.jsx
      validateCard.js
  shared/
    ui/                   Button.jsx  Dialog.jsx  Spinner.jsx
    lib/                  formatPrice.js  http.js
    hooks/                useDebounce.js

03Draw the arrows: who may import whom

Dependencies point one way. app may import anything. A feature may import shared and other features' public APIs. shared imports nothing from the app. When an arrow would point backwards, the code is in the wrong folder.

Draw the arrows: who may import whomdiagram
Rendering diagram…

04Give each feature one front door

features/cart/index.js lists what the rest of the app may use. Notice what is missing: formatLine and CartContext stay private, so the cart team can change them freely.

src/features/cart/index.jswhole filejavascript
export { CartProvider } from "./CartContext";
export { CartSummary } from "./CartSummary";
export { useCart } from "./useCart";
// formatLine.js and cartApi.js are deliberately not exported

05Set up the @/ alias

A path alias lets every file write @/features/cart instead of ../../features/cart. In a Vite project it is configured twice: once for the bundler and once for the editor and TypeScript (tsconfig.json or jsconfig.json).

vite.config.jswhole filejavascript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
  },
});

// tsconfig.json (or jsconfig.json):
// { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

06Enforce the boundary with a lint rule

ESLint's built-in no-restricted-imports rule can forbid deep imports into a feature. Now a reviewer doesn't have to spot them: the linter fails in the editor and in CI (continuous integration, the checks that run on every pull request).

eslint.config.jswhole filejavascript
export default [
  {
    files: ["src/**/*.{js,jsx}"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{
          group: ["@/features/*/*"],
          message: "Import a feature through its public index: @/features/<name>",
        }],
      }],
    },
  },
];

Try it yourself

  1. 1

    Try to reach a private helper

    In the first example, add console.log(typeof cart.formatLine); before the mount line. Predict the output, then run it.

    You get undefined: the helper exists inside the module but isn't part of its public API. Real ES modules behave the same way: a name that isn't exported can't be imported.

  2. 2

    Add a second public component

    Inside the cart module, write a CartCount component that shows the total quantity (items.reduce((n, i) => n + i.qty, 0)). Export it by adding it to the returned object and render <cart.CartCount /> in App. Which other code had to change? None outside the cart.

  3. 3

    Shrink the barrel

    In the barrel example, change uiBarrel so it only includes ButtonFile(). Predict the first log line. This is what removing heavy, rarely used components from a shared barrel does for the dev server.

Code & diagrams

A feature with a public API (one file pretending to be several) New tab

The cart "module" returns only its public API, like `features/cart/index.js`. `ProductCard` in another feature can add items but can't reach `formatLine` or the context object. Click **Add** a few times.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

cart exports: [ 'CartProvider', 'CartSummary', 'useCart' ]
formatLine is private: true
What importing from a barrel really loads (a model) New tab

A model, not a real bundler: it shows that the module loader must run every file a barrel re-exports before it can hand you one name. In the dev server and in tests that cost is paid on every page load and every test file.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Importing Button from the barrel ran: Button.jsx (2 KB), DatePicker.jsx (48 KB), Chart.jsx (160 KB), RichEditor.jsx (220 KB)
Importing the file directly ran: Button.jsx (2 KB)
The boundary rule catching a deep import (real ESLint 9 output)bash
$ cat src/features/checkout/CheckoutPage.jsx
import { CartSummary } from "@/features/cart";
import { formatLine } from "@/features/cart/utils/formatLine";

$ npx eslint src

/home/you/shop/src/features/checkout/CheckoutPage.jsx
  2:1  error  '@/features/cart/utils/formatLine' import is restricted from being used by a pattern. Import a feature through its public index: @/features/<name>  no-restricted-imports

✖ 1 problem (1 error, 0 warnings)
Allowed import directionsdiagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A barrel import inside the same feature creates a cycle

In features/cart/CartSummary.jsx, import { useCart } from "@/features/cart" (its own barrel) instead of "./useCart", while index.js also exports CartSummary and useCart is declared with export const.

terminal
$ Browser console (Vite dev server)
── what you'll see ──
Uncaught ReferenceError: Cannot access 'useCart' before initialization

Break #2

Shared code that knows about a feature

Make shared/ui/Button.jsx import useCart so it can show the cart count on every button.

terminal
$ Code review / dependency graph
── what you'll see ──
shared/ui/Button.jsx -> features/cart/index.js -> features/cart/CartSummary.jsx -> shared/ui/Button.jsx (cycle)

Myth vs fact

Myth

There is one official React folder structure.

Fact

React has no opinion about folders. Frameworks have conventions for routes (app/ in Next.js, routes/ in React Router framework mode), but how you group the rest is your choice. Feature folders are the most common answer for medium and large apps.

Myth

Barrel files are always bad.

Fact

They're a cheap, clear way to give a feature a public API. The problems come from huge barrels of unrelated things (shared/ui/index.js with 200 components) and from importing a feature's own barrel inside it.

Myth

Tree shaking means barrel files cost nothing.

Fact

Production bundlers can often remove unused re-exports, but only for side-effect-free modules, and the dev server and test runner still load every file. The cost shows up as slow page reloads and slow tests, not as a bigger bundle.

Myth

Feature folders mean nothing is ever shared.

Fact

Generic pieces still go in shared/. The rule is to start code inside the feature that needs it and move it to shared when a second feature needs it, not before.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Next.js has a built-in fix for the barrel cost of big libraries: experimental.optimizePackageImports in next.config rewrites import { X } from "lib" to the file that defines X, and is on by default for many popular packages (icon libraries, lodash-es, date-fns). For your own code, prefer direct imports from big shared folders.

  • ▸

    Mark packages as side-effect free with "sideEffects": false in package.json (or list the files that do have side effects, such as CSS imports). Without it, a bundler must keep every re-exported module that might run code at import time.

  • ▸

    Popular named approaches are variations of the same idea: Feature-Sliced Design formalises layers (app → pages → widgets → features → entities → shared) with lint rules, and Bulletproof React is a widely copied example repo with features/ plus components/, hooks/, lib/. Pick one, write it down in the README, and enforce it.

  • ▸

    Measure the barrel problem instead of guessing: open the Network panel in the Vite dev server and count the module requests on first load, then switch a hot import from the barrel to a direct file import and count again. Do the same with your test run's total duration.

Remember this

  1. 1

    A type-based structure puts every component in components/, every hook in hooks/, every helper in utils/. It works for small apps. As the app grows, one change ("add a discount code to the cart") touches five folders, and nobody can tell which hooks belong to which screen, so dead code piles up because deleting anything feels risky.

  2. 2

    A feature-based structure (also called *feature folders* or *vertical slices*) gives each product area its own folder holding its components, hooks, API calls, state, styles and tests: src/features/cart/. A change to the cart touches one folder. Deleting a feature means deleting one folder. This is the same idea as colocation from Topic 6.6 (keep state close to where it's used), applied to files: keep things next to the code that uses them, and move them up to a shared place only when a second user appears.

  3. 3

    Shared UI (src/shared/ui/ or src/components/ui/) holds generic building blocks: Button, Dialog, Input, Spinner. The rule that keeps it healthy: shared code never imports from features. A Button must not know what a cart is. If a piece of UI mentions a product idea (a CartIcon with a live count), it belongs to that feature, which can build it from the shared Button.

  4. 4

    A public API for a feature is one file, usually features/cart/index.js, that re-exports only what other parts of the app may use (CartProvider, CartSummary, useCart). Everything else in the folder is private by convention. Other features import @/features/cart, never @/features/cart/utils/formatLine. This lets the cart team rename and restructure inside the folder without breaking anyone.

  5. 5

    A file that only re-exports other files is a barrel file. Barrels are useful as a feature's public API, but they have costs. Importing one name from a barrel makes the module loader evaluate every file the barrel re-exports. In a Vite dev server or a Vitest run (which load modules one by one, not bundled) a big shared/ui/index.js can mean hundreds of extra files per page or test. Production bundlers can usually drop the unused code (tree shaking), but only when files have no side effects, and barrels also make circular imports easy to create by accident. Rule of thumb: one barrel per feature as its public door, no barrels inside a feature, and direct file imports for big shared libraries.

  6. 6

    An import boundary is a rule about which folders may import which: app → features → shared, never backwards, and features talk to each other only through public APIs. Written in a README, such rules decay. Enforced by a lint rule (no-restricted-imports in ESLint, or tools such as eslint-plugin-boundaries and dependency-cruiser), they fail the build the moment someone breaks them.

Explain it without notes

01

Compare a type-based folder structure with a feature-based one. When does each make sense?

02

What is a barrel file, and what are its trade-offs?

03

What is an import boundary and how would you enforce one in a React codebase?

04

Where should a component go if two features need it?

Practice

01

Here is a type-based structure: components/{TodoList,TodoItem,LoginForm,Avatar}.jsx, hooks/{useTodos,useAuth}.js, utils/{formatDate,validateEmail}.js. Reorganise it into features and shared code.

02

Write the no-restricted-imports rule that stops any file from importing @/features/<name>/<anything>, and say what @/features/todos should export.

Trade-offs

  • ↔

    Feature folders make ownership and deletion easy but need judgment about what is "shared"; teams that move things to shared/ too early end up with a second type-based junk drawer.

  • ↔

    Barrels give clean public APIs at the price of dev-server and test speed and a risk of cycles. Keep them small and at feature boundaries only.

  • ↔

    Lint-enforced boundaries stop architecture from decaying but add friction: every exception needs a config change. That friction is usually the point.

Done when you can

  • I can explain why feature folders scale better than type folders.

  • I can decide whether a component belongs in a feature or in shared/.

  • I can give a feature a public index.js and keep its helpers private.

  • I can explain the dev-server, tree-shaking and circular-import costs of barrel files.

  • I can set up an @/ path alias and a lint rule that blocks deep imports.