Where files go is the one decision in a React app that nobody makes on purpose. You scaffold with
Vite, you get a src/ with App.tsx in it, you add a
components/ folder because you have a component, and eighteen months later forty people
are arguing about it in a pull request.
This lesson is about the version of that decision that matters: a codebase big enough that no one person has read all of it. React itself has no opinion here — it ships a component model and nothing else — so the structure is entirely yours, and it is the part of the app that is hardest to change later.
Start with what the pizza app actually does
Every other lesson in this track takes its snippets from one working application. Here is its
whole src/:
src/
App.tsx
main.tsx
components/ AppNavbar CartDrawer ErrorBoundary Footer
PizzaBuilderModal ProductCard ProtectedRoute StripePaymentForm
context/ AuthContext CartContext MenuContext ToastContext
pages/ Home Menu Checkout OrderConfirmation Login Register
Orders Profile InterviewQuestions
pages/admin/ AdminLayout AdminReports AdminProducts AdminToppings
AdminCrusts AdminOrders AdminUsers
lib/ api.ts adminApi.ts money.ts profileApi.ts stripe.ts
store/ index.ts catalogSlice ordersSlice reportsSlice usersSlice apiFailure
types/ index.ts
data/ interviewQuestions.ts
styles/ theme.scss _tokens.scss
assets/ hero.png vite.svgThat is 43 TypeScript files and about 6,850 lines, organised by what each file is: components with components, pages with pages, types with types. Call it the type-based layout. It is what every tutorial shows you, including the early lessons of this one.
And at this size it is the right answer. You can hold 43 files in your head. When
someone says "the cart drawer", you know it is in components/ without looking. There is
no ceremony, no indirection, and a new developer is productive in an afternoon.
Do not let the rest of this lesson talk you out of that. The question is not whether type folders are bad — it is what specifically breaks when the app is ten times bigger, because that tells you what the replacement has to fix.
Where type folders start to hurt
Four things go wrong, and all four are visible in the pizza app already, in miniature.
One feature is spread across five folders
"The cart" is a single idea a product manager can describe in one sentence. Here is where it lives:
context/CartContext.tsx the state, the reducer, the server sync 383 lines
components/CartDrawer.tsx the slide-out UI 140 lines
components/PizzaBuilderModal.tsx the thing that adds items to it 263 lines
pages/CheckoutPage.tsx where the cart is turned into an order 526 lines
lib/money.ts subtotal, tax, delivery fee, line totals 57 lines
types/index.ts CartItem, ServerCart, CartWriteRequest (buried in 337)Five folders, six files, and not one of them sits next to another. Now imagine the request "remove the cart and go to single-item instant checkout". You cannot answer "which files?" by looking — you have to grep, and grep will miss the type definitions that no longer have a reader.
This is the cost that scales. The number of folders you visit to change one feature stays roughly
constant as the app grows, but the number of unrelated files you have to scroll past inside
each folder does not. A components/ directory with 300 files in it is a directory you
navigate with a fuzzy finder, which means the folder structure has stopped carrying information.
lib/ is where feature logic goes to hide
This is the interesting one, because it is a real bug in the pizza app's structure and it is measurable.
lib/money.ts is imported by 14 of those 43 files. That sounds like a
well-used shared utility. Look at what each file actually asks for:
context/CartContext.tsx { calculateTotals, type CartTotals }
components/CartDrawer.tsx { formatMoney, lineTotal, unitPrice }
components/PizzaBuilderModal.tsx { formatMoney, round2 }
pages/CheckoutPage.tsx { formatMoney, lineTotal }
components/ProductCard.tsx { formatMoney }
components/StripePaymentForm.tsx { formatMoney }
pages/HomePage.tsx { formatMoney }
pages/OrdersPage.tsx { formatMoney }
pages/OrderConfirmationPage.tsx { formatMoney }
pages/admin/AdminOrdersPage.tsx { formatMoney }
pages/admin/AdminProductsPage.tsx { formatMoney }
pages/admin/AdminCrustsPage.tsx { formatMoney }
pages/admin/AdminToppingsPage.tsx { formatMoney }
pages/admin/AdminReportsPage.tsx { formatMoney }Nine of the fourteen want one function — formatMoney, a three-line
wrapper around Intl.NumberFormat that has nothing to do with pizza. The other five want
the pricing rules. But they are the same module, so they are the same dependency:
// lib/money.ts — one module, two completely different jobs.
export function formatMoney(amount: number): string { // genuinely shared: any app, any domain
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
export const TAX_RATE = 0.085; // cart + checkout business rules
export const DELIVERY_FEE = 3.99;
export function calculateTotals(items: CartItem[], orderType: OrderType): CartTotals { /* … */ }The result: the admin reports screen depends on the delivery fee. Not because it uses it — it does not — but because it needed a currency formatter and that is the module the formatter was in. Change how delivery is priced and the blast radius, as far as any tool can tell, includes five admin screens.
Nothing about type folders forces this. What they do is make it the path of least resistance:
lib/ has no stated admission criteria, so anything vaguely reusable lands there, and
each arrival quietly widens what the rest of the app depends on.
Ninety-nine relative imports
$ grep -rho "from '\(\.\./\)\+[^']*'" src | wc -l
99
$ grep -rho "from '\(\.\./\)\{2,\}[^']*'" src | wc -l
36Ninety-nine imports reach out of their own folder, thirty-six of them by two or more levels, and
the deepest are the honest ones — ../../store, ../../types,
../../lib/money, ../../context/ToastContext.
Counting dots is a small daily tax. The real cost is that ../../ encodes the
current folder depth into every file, so moving a file breaks imports that had no opinion
about where it lived. In a codebase where reorganising is normal, that friction is enough to stop
people reorganising.
Nothing stops the wrong import
The pizza app is careful about one boundary in particular. Redux and the six admin screens are
lazily loaded, and <Provider store={store}> is deliberately mounted inside
AdminLayout rather than main.tsx, so Redux lands in the admin chunk and
costs customers nothing — Code Splitting shows the 430 kB
that saves.
That boundary is held up by a comment and by whoever reviews the pull request. Add
import { store } from '../store' to HomePage.tsx and everything still
compiles, the tests still pass, the lint still passes — and 37 kB of Redux moves into the bundle
every customer downloads. You find out from a bundle-size graph, weeks later, if you are watching
one.
An architecture that only exists in people's heads is not an architecture. It is an agreement, and agreements decay at exactly the rate your team changes.
The shape that scales: organise by feature
The fix is to make the folder structure say what the app does rather than what its files are. Three top-level directories:
src/
app/ wiring: entry point, router, providers, global styles
features/ one folder per thing the product does
shared/ genuinely generic code, owned by nobody in particularAnd inside a feature, the type folders come back — scoped to that feature, where they are useful again because there are eight files in them and not three hundred:
src/features/cart/
components/ CartDrawer.tsx PizzaBuilderModal.tsx CartSummary.tsx
hooks/ useCart.ts
api/ cartApi.ts
model/ cartReducer.ts CartProvider.tsx pricing.ts
types.ts CartItem ServerCart CartWriteRequest CartTotals
index.ts the feature's public API — see below
That is the whole idea: a feature is a vertical slice. Its UI, its state, its
network calls, its types and its business rules are one directory, because they change together. The
"remove the cart" request becomes rm -r src/features/cart plus whatever the compiler
then complains about — and the compiler complaining is the point.
A feature is not a route and it is not a component. checkout is a feature;
Button is not; /order-confirmation/:orderId is a route that the
orders feature happens to own. The test is whether a non-engineer would name it when
describing the product.
The three rules that make it work
The folders are the easy half. Enterprise codebases that fail at this usually did the folders and skipped the rules, and ended up with the same tangle in prettier packaging.
1. A feature owns its whole vertical slice
If a type, a fetch call or a pricing rule is used by one feature, it lives in that feature — not in
a global types/ or lib/. The pizza app's types/index.ts is 337
lines imported by 23 files; split by owner, CartItem and ServerCart go to
features/cart/types.ts, Order and OrderStatus to
features/orders/, and only the handful that genuinely cross features — UUID,
Page<T>, ApiErrorBody — stay shared.
Duplication is cheaper than the wrong abstraction here. Two features with a
similar-looking Address type are usually two types that are about to diverge. Wait for
the third use before promoting anything to shared/.
2. Features do not reach into each other
Every feature exports a public API from its index.ts, and that is the only thing
other features may import:
// features/cart/index.ts — the contract. Everything not listed here is private.
export { CartProvider, useCart } from './model/CartProvider';
export { CartDrawer } from './components/CartDrawer';
export type { CartItem, CartTotals } from './types';
// Deliberately NOT exported: cartReducer, pricing.ts, cartApi.ts, CartSummary.
// They are implementation. Nobody outside the cart should be able to depend on them.// features/checkout/CheckoutPage.tsx
import { useCart } from '@/features/cart'; // ✅ the public API
import { useCart } from '@/features/cart/model/CartProvider'; // ❌ reaching insideTwo features that need the same thing is a signal, not a problem to route around: either it belongs
in shared/, or one of them should be asking the other through its public API, or — most
often — they are actually one feature that has been split along the wrong line.
3. Dependencies point one way
app/ ───────► features/ ───────► shared/
app may import anything. It is the wiring; that is its job.
features may import shared, never app,
and never another feature's internals.
shared may import NOTHING from features. This is the rule that decays first.That last line is where lib/money.ts went wrong. It is nominally shared and it
imports CartItem, which points backwards. Under the one-way rule the fix is forced and
obvious:
shared/lib/currency.ts formatMoney, round2 // no domain types, importable by anyone
features/cart/model/pricing.ts TAX_RATE, DELIVERY_FEE, unitPrice, lineTotal, calculateTotalsThe nine files that only wanted a formatter now depend only on a formatter, and the admin reports screen stops depending on the delivery fee.
Mapping the pizza app onto it
Concretely, for the files above:
| Feature | Absorbs |
|---|---|
features/menu/ | MenuContext, MenuPage, ProductCard, the catalogue types |
features/cart/ | CartContext, CartDrawer, PizzaBuilderModal, the pricing half of money.ts, the cart types |
features/checkout/ | CheckoutPage, StripePaymentForm, lib/stripe.ts |
features/auth/ | AuthContext, LoginPage, RegisterPage, ProtectedRoute, tokenStore |
features/orders/ | OrdersPage, OrderConfirmationPage, the order types |
features/profile/ | ProfilePage, lib/profileApi.ts, Address and PaymentMethod |
features/admin/ | all seven admin pages, all four Redux slices, store/, lib/adminApi.ts |
shared/ | the api client, ErrorBoundary, ToastContext, formatMoney, UUID, Page<T>, ApiErrorBody |
app/ | main.tsx, App.tsx, AppNavbar, Footer, styles/ |
Three things in that table are worth arguing about, and the arguments are the useful part.
features/admin/ is one feature, not six. The six CRUD screens share
the Redux store, the admin API client and the layout, and they are always changed together. Splitting
them would create six folders that all reach into each other — rule 2 violated by construction.
AppNavbar lives in app/, not shared/. It
reads useAuth, useCart and useMenu — it is downstream of three
features at once, which is exactly what app/ is for. Putting it in shared/
would break the one-way rule on day one.
ToastContext is shared, CartContext is not. Toasts are
a generic notification mechanism with no domain knowledge; the cart is the domain. Same file
extension, same context/ folder today, opposite sides of the boundary.
Path aliases
Feature folders are deeper than type folders, so they make ../../ worse before they
make it better. Fix that first — it is two small edits:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});// tsconfig.app.json — the bundler and the typechecker are separate resolvers.
// Configure one and the app runs but the editor shows red; configure the other
// and the types are fine but the build fails. Both, always.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Now an import says where something is rather than how far away it is:
import { formatMoney } from '../../lib/money'; // before
import { formatMoney } from '@/shared/lib/currency'; // after — same from any depthOne alias, not six. Teams that add @features,
@components, @hooks, @utils end up needing a lookup table to
read an import statement. A single @/ root keeps the path readable and keeps
vite.config.ts, tsconfig.json, the test runner and Storybook in step.
Make the boundary a build error
This is the step that separates an architecture from a convention, and it is the one most teams skip. Rules 2 and 3 are mechanical — a linter can check them, so a linter should.
// eslint.config.js — flat config, with eslint-plugin-import installed.
import importPlugin from 'eslint-plugin-import';
export default [
{
files: ['src/**/*.{ts,tsx}'],
// The plugin has to be REGISTERED here, or the `import/` rule below
// fails to resolve and ESLint exits with "Definition not found".
plugins: { import: importPlugin },
// ⚠️ Without this the rule below silently does nothing for aliased
// imports. See the note after this block — it is not optional.
settings: { 'import/resolver': { typescript: { project: './tsconfig.json' } } },
rules: {
'import/no-restricted-paths': ['error', {
zones: [
{
// shared/ must not know that features exist.
target: './src/shared',
from: './src/features',
message: 'shared/ cannot import from features/.',
},
{
// features/ must not reach back into the app wiring.
target: './src/features',
from: './src/app',
message: 'features/ cannot import from app/.',
},
],
}],
// A feature may import another feature's index.ts, and nothing deeper.
// (Core ESLint rule — no plugin needed for this one.)
'no-restricted-imports': ['error', {
patterns: [{
group: ['@/features/*/*'],
message: 'Import the feature root, not its internals.',
}],
}],
},
},
];The resolver line is load-bearing
That settings line is the one to get right, because leaving it out fails in the worst
possible way: quietly, and only for the imports you care about.
import/no-restricted-paths works on resolved paths. It has to turn the
import string into a file on disk before it can decide which zone that file is in. Relative imports
resolve out of the box, so the rule fires. An aliased import does not — and an unresolvable import is
not an error to this rule, it is simply skipped:
# src/shared/lib/currency.ts — both lines break the same rule.
import { useCart } from '../../features/cart'; # caught
import { useCart } from '@/features/cart'; # NOT caught, no resolver configured
$ npx eslint src
✖ 1 problemSo you adopt @/ aliases in one section, add the boundary rule in the next, watch it
catch a violation in the test you wrote, and ship an enforcement rule that is a no-op against every
import written in the style you just standardised on. Install
eslint-import-resolver-typescript, point it at the tsconfig that defines
paths, and both lines report:
$ npx eslint src
1:25 error Unexpected path "@/features/cart" imported in restricted zone.
shared/ cannot import from features/ import/no-restricted-paths
✖ 2 problemsTest the rule by writing a violation on purpose and confirming the error, every time you add a zone. A boundary rule that has never failed is indistinguishable from one that cannot fail.
Cross-feature imports need one more turn of the screw to be airtight — the pattern above stops
deep imports but still lets any feature import any other feature's public API. Teams that want the
stricter version generate a per-feature override, or reach for
eslint-plugin-boundaries, which is built for exactly this and lets you declare element
types and an allowed-dependency matrix.
The pizza app does not have this. It lints with oxlint, which is
fast and catches real bugs but is not configured with any import-boundary rule, so every boundary
described above is currently held up by review alone. That is fine for 43 files and one author. It is
not fine for 400 files and twenty authors, and the day it stops being fine is not announced.
One more option worth knowing at the top end: TypeScript project references, or a
real monorepo with each feature as a workspace package. Then the boundary is enforced by the module
resolver rather than a lint rule, a feature's package.json lists what it may depend on,
and a violation is not lintable-away. It is heavier than most apps need — but if your "enterprise
app" is genuinely several teams shipping on independent cadences, this is the line past which folder
conventions stop being enough.
Where state goes once you have features
Structure answers a question that Context and Redux leave open: not "which state tool", but "where does this state live". Feature folders make the answer mostly mechanical.
| Scope | Where it goes | In the pizza app |
|---|---|---|
| One component | useState, in the component | cartOpen in App.tsx |
| One feature | a provider inside that feature, exported via its hook | CartProvider + useCart |
| Several features | shared/, mounted in app/ | AuthProvider, ToastProvider |
| One big subsystem | a store inside that feature, provided by its layout | Redux inside AdminLayout |
| Server data | a query cache, not a store | — |
Two of those rows carry most of the weight.
The Redux row is a structural decision, not a state-management one. Keeping the
store inside features/admin/ rather than at the root is what makes it possible for it to
be lazily loaded, and what stops a customer-facing component from dispatching into it. Where a
provider is mounted decides both the bundle and the boundary.
The last row is the one people get wrong. Most of what ends up in a global store
is not application state — it is a cached copy of the server, with loading flags and refetch logic
hand-written around it. That is what TanStack Query or RTK Query are for, and adopting one typically
deletes more code than it adds. The pizza app hand-rolls it in MenuContext and the admin
slices, which is instructive to read and not what a new enterprise app should do.
Tests and stories next to the code
A vertical slice should be vertical all the way down:
features/cart/components/
CartDrawer.tsx
CartDrawer.test.tsx
CartDrawer.stories.tsxA parallel tests/ tree that mirrors src/ guarantees two things: the
mirror drifts, and moving a file means editing two paths. Colocated tests move with the code, and a
feature folder you can delete in one command is one where the tests go with it.
End-to-end tests are the exception and belong at the root — the pizza app keeps them in
e2e/, because a checkout journey crosses menu, cart, auth and orders and is owned by
none of them.
What not to do
Atomic design as a folder structure. atoms/,
molecules/, organisms/ is a vocabulary for designers that becomes an
unwinnable argument for engineers — every code review acquires a debate about whether a thing is a
molecule. It is the type-based layout with worse names.
A folder per component. Button/Button.tsx,
Button/Button.test.tsx, Button/Button.module.css,
Button/index.ts — four files and two levels for something that is sometimes twenty
lines. Do it when a component genuinely has several files; do not do it by policy.
A utils/ folder. Same failure mode as lib/, one step
further along: a module named for what it is not. Name modules for what they do —
currency.ts, dates.ts, pricing.ts — and it stays obvious when
something does not belong.
Structuring for a scale you do not have. Seven feature folders each containing one component, wrapped in barrels, behind five aliases, is not enterprise-ready — it is a small app with a large tax on it.
And a note on barrel files
Rule 2 needs an index.ts per feature, and that is the right number of barrels. Do not
extend the habit downwards. An index.ts in every sub-folder re-exporting everything
beneath it creates import cycles that are miserable to unpick, and makes it easy to pull a whole
feature into a chunk that wanted one function from it — precisely the code splitting you worked for.
Keep barrels at the feature boundary, where they are a contract, and nowhere else, where they are
just indirection.
When to actually move
Not on day one. A new app should start as the pizza app is today — flat, type-based, obvious — and restructure when the symptoms show up:
- A
components/orpages/folder you scroll to navigate. - More than one team shipping in the same repo.
- Changes that routinely touch four folders to do one thing.
- A
lib/orutils/module that half the app imports for different reasons — themoney.tssymptom. - A code review where somebody has to explain, again, why a given import is not allowed.
The last one is the real trigger. It means the architecture exists only in the heads of the people who happen to be reviewing, and the cost of writing it down as a lint rule is now lower than the cost of not.
Migrate one feature at a time. Add the alias, create src/features/,
move the most self-contained feature into it with its types and its API calls, give it an
index.ts, add the lint rule for that feature alone, and ship. A big-bang reorganisation
is a pull request nobody can review and a merge conflict with everything in flight.
That is the track
Twenty-seven lessons, from creating a project to shipping one and organising it for a team. Along the way: components, JSX, props and state; effects, refs and error boundaries; context, reducers, custom hooks and Redux; routing, memoisation, code splitting, styling, and the folder structure that holds it together.
Every example came from the same working application, which is also the best next step available — build something with a cart, a form, a list and a route in it, and the parts that felt abstract stop being abstract.
For reference beyond this track, react.dev is genuinely excellent and worth reading rather than only searching.
Next
Interview Questions — twenty-four senior-level questions covering the whole track, including a section on Context, with the five that matter most flagged.