Structuring an app

Decide where each piece of code lives, so the app stays readable at fifty components.

18of 20
≈ 11Min read
7Parts

Group by feature, not by kind

The alternative — components/, hooks/, utils/ at the top level — means every change to one feature touches four folders, and nothing tells you which files belong together. Group by feature and a feature can be read, moved or deleted in one place.

the folder layout that survives
src/
  features/
    doors/
      DoorsPage.jsx        ← the screen
      DoorList.jsx
      DoorRow.jsx
      useDoors.js          ← the logic
      doors.api.js         ← how it talks to the server
      doors.test.js
    orders/
  components/              ← shared, dumb, reusable: Button, Field, Modal
  hooks/                   ← shared hooks: useMediaQuery, useClickOutside
  lib/                     ← pure helpers: format.js, dates.js
  App.jsx
  main.jsx

Three kinds of component

The split is not a rule to apply everywhere; it is what happens naturally when you keep the logic in hooks. A presentational component is easy to read, easy to reuse and trivially testable, precisely because it only draws what it was handed.

KindKnows aboutTest by
PresentationalIts props only. No state, no hooks, no fetching.Rendering it with props
Container / screenThe data, the hooks, the routingRendering the screen
Shared UINothing about your domain — Button, FieldA story or a snapshot

Where logic lives

  • Pure functions — formatting, sorting, validation, money and dates — in lib/, with no React import. These are the easiest things in your codebase to test.
  • Stateful logic — in a custom hook next to the feature.
  • Server calls — in one *.api.js per feature, so the component never sees a URL or a header.
  • Markup — in the component, and nothing else.
the same screen, layered
// lib/format.js
export const formatDays = (d) => (d === 0 ? "—" : d === 1 ? "1 day" : d + " days");

// features/doors/useDoors.js
export function useDoors() { … returns { doors, add, remove } }

// features/doors/DoorRow.jsx — presentational
export function DoorRow({ door, onRemove }) { … }

// features/doors/DoorsPage.jsx — the screen
export function DoorsPage() {
  const { doors, add, remove } = useDoors();
  return <DoorList doors={doors} onRemove={remove} />;
}

How big is too big

  • A component you cannot see the shape of on one screen is usually two components.
  • More than three or four useState calls in one component: consider a reducer.
  • A component that both fetches, filters, sorts and renders: the fetching and the filtering want to be a hook.
  • But a 150-line component that does one thing and is used once is fine. Splitting it into five files that only ever call each other makes it harder to read, not easier.

Naming and files

  • PascalCase.jsx for components, one main component per file, named after the file.
  • camelCase.js for hooks and helpers: useDoors.js, format.js.
  • Named exports everywhere except where a tool demands a default (route files, some bundler conventions). Named exports survive renaming and autocomplete better.
  • Avoid index.js barrel files that re-export a whole folder — they defeat tree shaking and make circular imports easy.

Imports that stay readable

an alias beats ../../../
// vite.config.js
resolve: { alias: { "@": path.resolve(__dirname, "./src") } }

import { Button } from "@/components/Button.jsx";
import { useDoors } from "@/features/doors/useDoors.js";
One direction only

A feature may import from components/, hooks/ and lib/. Those must never import from a feature — that is the circular dependency that eventually breaks your build for reasons nobody can explain.

What to decide on day one

  • Where server calls live.
  • Whether you have a shared component library folder, and what is allowed in it.
  • One formatting and linting setup, applied on save.
  • Nothing else. Structure earned from real files beats structure invented in advance — a folder you created because you might need it is a folder someone will fill with the wrong thing.

Practice