React

Structuring Large React Apps

How to organise a React application that will grow: feature-based folders, colocated tests, shared primitives, and the rule that prevents premature abst...

The hardest part of scaling a React codebase isn't the code itself. It's the folder structure. Get it wrong early, and every new feature becomes an archaeology project—digging through directories to find where something lives.

This is how I think about it.

Type-Based vs Feature-Based

Most tutorials start you here:

src/
  components/
  hooks/
  utils/
  pages/

That works fine when the app is small. Every file has a clear home. But as the project grows, each of those folders balloons into dozens of unrelated files. components/ ends up holding a date picker, a nav bar, a product card, and a modal confirmation dialog that only one page uses. They have nothing to do with each other except that they all happen to be components.

The alternative is to organise by feature instead:

src/
  features/
    auth/
      LoginForm.tsx
      useAuth.ts
      auth.utils.ts
      LoginForm.test.tsx
    products/
      ProductCard.tsx
      ProductList.tsx
      useProducts.ts
      products.utils.ts
      ProductCard.test.tsx
    checkout/
      CheckoutForm.tsx
      useCheckout.ts
      CheckoutForm.test.tsx
  shared/
    components/
      Button.tsx
      Modal.tsx
      Input.tsx
    hooks/
      useDebounce.ts
      useLocalStorage.ts
    utils/
      format.ts
      cn.ts

Now when you're working on the checkout flow, everything you need is in one place. The component, its hook, its tests, its utilities. No context switching across the project.

Colocation

The feature-based structure only works if you commit to colocation: keep files close to the code that uses them.

Tests live next to the component they test. A utility function used only in one feature lives in that feature's folder. A type definition used only in one hook lives in the same file as the hook.

The instinct is to centralise everything. One __tests__ folder, one types/ folder, one constants/ file. That instinct comes from a good place (discoverability) but it creates friction. When you delete a component, you have to hunt down its test in another directory. When you rename a type, you search a separate types folder you forgot you had.

Colocation makes deletion easy. When a feature is removed, you delete one folder and everything related to it goes with it.

The shared/ Directory

Some things genuinely belong everywhere: a Button component, a useDebounce hook, a cn() utility for combining class names. These go in shared/ (or common/, the name doesn't matter much).

The important thing is what doesn't go there. Not every component that appears on more than one page belongs in shared/. A ProductCard that's used on the product listing and the search results page is a product concern, not a shared primitive. It belongs in features/products/.

shared/ should contain only components and utilities that are truly domain-agnostic. If you look at a file in shared/ and it has any awareness of your application's business logic, it's in the wrong place.

When to Create an Abstraction

The question that trips up most teams: when does a piece of logic get extracted into its own hook or utility?

My rule is three instances. The first time you write something, write it inline. The second time you need something similar, write it again (with a comment if you like). The third time, extract it.

One use: not worth abstracting. You don't know what the abstraction looks like yet.

Two uses: still not clear. Coincidence is not a pattern.

Three uses: you have enough signal. The abstraction is justified and you probably know what shape it should take.

This matters because premature abstraction is worse than duplication. A badly-shaped abstraction forces every future use case to work around it. Duplication can be cleaned up with a rename. A leaky abstraction infects the codebase.

The same principle applies to folder structure. Don't create a features/payments/ folder for a payment button that only does one thing. Add it to features/checkout/ until it's large enough to warrant its own home.

A Practical Starting Point

For a new project, I'd start with a flat structure and add feature folders as domains emerge:

src/
  app/              # routing (Next.js or React Router)
  features/         # add subdirectories as features grow
  shared/
    components/
    hooks/
    utils/
  types/            # global TypeScript types only

Resist the urge to create every folder on day one. Let the structure reflect what the code actually is, not what you imagine it might become.

The structure that's easiest to change is the one you should choose. Feature-based folders with colocated files make it easy to move things, delete things, and understand things. That's the goal.

← Older
Testing React Components with Vitest and Testing Library
Newer →
State Management Without Redux

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.