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
ButtonandDialogthat 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.jsthat 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 atsrc/, 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/
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.css02After: 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/
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.js03Draw 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.
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.
export { CartProvider } from "./CartContext";
export { CartSummary } from "./CartSummary";
export { useCart } from "./useCart";
// formatLine.js and cartApi.js are deliberately not exported05Set 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).
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).
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
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
Add a second public component
Inside the cart module, write a
CartCountcomponent 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 />inApp. Which other code had to change? None outside the cart. - 3
Shrink the barrel
In the barrel example, change
uiBarrelso it only includesButtonFile(). Predict the first log line. This is what removing heavy, rarely used components from a shared barrel does for the dev server.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
cart exports: [ 'CartProvider', 'CartSummary', 'useCart' ]
formatLine is private: trueA 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
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)$ 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)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.
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.
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.optimizePackageImportsinnext.configrewritesimport { X } from "lib"to the file that definesX, 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": falseinpackage.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 withfeatures/pluscomponents/,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
A type-based structure puts every component in
components/, every hook inhooks/, every helper inutils/. 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
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
Shared UI (
src/shared/ui/orsrc/components/ui/) holds generic building blocks:Button,Dialog,Input,Spinner. The rule that keeps it healthy: shared code never imports from features. AButtonmust not know what a cart is. If a piece of UI mentions a product idea (aCartIconwith a live count), it belongs to that feature, which can build it from the sharedButton. - 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
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.jscan 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
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-importsin ESLint, or tools such aseslint-plugin-boundariesanddependency-cruiser), they fail the build the moment someone breaks them.
Explain it without notes
Compare a type-based folder structure with a feature-based one. When does each make sense?
What is a barrel file, and what are its trade-offs?
What is an import boundary and how would you enforce one in a React codebase?
Where should a component go if two features need it?
Practice
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.
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.jsand 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.