Project Structure and Best Practices

As a React project grows from a single App component into dozens of features, how you organize your files becomes just as important as the code inside them. React itself has no opinion about folder layout — it only cares about the component tree, not the file tree — which means a messy structure is entirely possible, and entirely your responsibility to avoid. A good project structure keeps related code together, makes it obvious where new code belongs, and lets your bundler split and tree-shake your app efficiently. This lesson walks through proven folder structures, naming conventions, and the mistakes that make React codebases hard to maintain as they scale.

Overview / How it works

React components are just JavaScript functions, and your bundler (Vite, webpack, Parcel, etc.) builds your app by starting at an entry file — usually src/main.jsx — and following every import statement it finds, building a module graph. Folder names carry zero runtime meaning to React; a component works identically whether it lives in src/Button.jsx or src/features/checkout/components/ui/Button/Button.jsx. Structure exists entirely for humans: it should make it obvious where new code goes, keep related files near each other, and prevent the codebase from turning into a pile of files that only makes sense to whoever wrote it.

There are two dominant approaches. Type-based structure groups files by what kind of thing they are: every component in components/, every hook in hooks/, every page in pages/. It is simple and works well for small apps, but it breaks down as an app grows, because a single feature (say, checkout) ends up with pieces scattered across five unrelated folders, and touching that feature means jumping around the whole tree. Feature-based structure (sometimes called colocation or "screaming architecture") instead groups files by the domain they belong to: everything checkout needs — its components, hooks, and API calls — lives inside features/checkout/. Most production React apps use a hybrid: feature folders for anything domain-specific, plus a small shared components/ or ui/ folder for truly generic, reusable pieces like a Button or Modal that many features use.

A useful rule of thumb is colocate first, extract when reused: when you create a new hook or helper, put it right next to the component that uses it. Only move it into a shared folder once a second, unrelated feature actually needs it. This avoids a common failure mode where every project immediately grows a bloated utils/ folder full of one-off functions nobody can find.

Two other structural tools matter here. A barrel file (an index.js that only contains re-exports) lets consumers import from a folder instead of a deep file path — import { TodoList } from "./features/todos" instead of reaching into ./features/todos/components/TodoList". A path alias (configured in your bundler) lets you write @/components/Button from anywhere in the project instead of counting ../../../../ segments. Both are conventions layered on top of plain JavaScript module resolution — React itself is unaware of either.

Syntax

There is no single "correct" structure, but most React apps converge on some version of this baseline, combining a few shared folders with feature folders:

src/
  assets/            images, fonts, static files
  components/        small, generic, reusable UI (Button, Modal, Input)
  features/          one folder per domain/feature
    todos/
      components/     UI specific to this feature
      hooks/          hooks specific to this feature
      api/            data-fetching for this feature
      index.js        barrel file (public exports of this feature)
  pages/              one file per route/screen
  hooks/              hooks shared across features
  lib/                framework-agnostic helpers, formatting, constants
  App.jsx             top-level layout, providers, routes
  main.jsx            entry point (createRoot)
Folder / File Purpose
components/ Generic, reusable UI with no knowledge of any specific feature
features/<name>/ Everything one domain needs: components, hooks, API calls, colocated
pages/ One component per route, usually thin — composes features together
hooks/ Custom hooks used by more than one feature
lib/ Plain JS helpers (formatting, validation) with no React dependency
App.jsx Providers, routing, and overall layout — no business logic

Examples

Example 1: A feature folder with a barrel file

The component below lives at src/features/todos/components/TodoList.jsx and consumes a colocated custom hook:

import { useState } from "react";
import { useTodos } from "../hooks/useTodos";

function TodoList() {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
  const [text, setText] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text);
    setText("");
  }

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Add a todo"
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </>
  );
}

export default TodoList;

The feature’s index.js barrel file re-exports the public pieces of this folder:

export { default as TodoList } from "./components/TodoList";
export { useTodos } from "./hooks/useTodos";

A consumer, such as src/App.jsx, never needs to know the internal layout of the feature — it just imports from the folder:

import { TodoList } from "./features/todos";

function App() {
  return (
    <div className="app">
      <h1>My Todos</h1>
      <TodoList />
    </div>
  );
}

export default App;

Notice the key={todo.id} on every list item — required any time you render a list with .map() — and that App.jsx stays a thin shell that composes the feature rather than containing any of its logic.

Example 2: Colocating a custom hook

The hook this feature depends on lives at src/features/todos/hooks/useTodos.js, right next to the components that use it instead of a generic top-level hooks/ folder:

import { useState, useCallback } from "react";

export function useTodos(initialTodos = []) {
  const [todos, setTodos] = useState(initialTodos);

  const addTodo = useCallback((text) => {
    setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
  }, []);

  const toggleTodo = useCallback((id) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }, []);

  const deleteTodo = useCallback((id) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }, []);

  return { todos, addTodo, toggleTodo, deleteTodo };
}

All the state and business logic for todos lives in one file, independent of how it is rendered. This makes it trivial to reuse the hook in a second component (say, a compact widget version of the todo list) or to unit test it without rendering any JSX at all.

Example 3: Splitting code by route/page

A pages/ folder maps naturally onto route-level code splitting with lazy and Suspense, so users only download the page they actually visit:

import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

export default App;

Because Dashboard and Settings each live in their own file under pages/, wrapping their imports in lazy() is a one-line change — the folder structure already drew the boundary the bundler needs.

Under the hood

Project structure has no effect on React’s render-reconcile-commit cycle for a single component; it only affects two things the bundler does at build and load time. First, module resolution: every import path is resolved at build time into an entry in the module graph, whether it’s a relative path or a configured alias — aliases are rewritten by the bundler, not by React. Second, code-splitting boundaries: when you wrap a component in lazy(() => import("./pages/Settings")), the bundler creates a separate chunk file for everything that module imports. On first render of that lazy component, React calls the import function, which returns a promise; the nearest <Suspense> ancestor renders its fallback while that promise is pending. When the chunk finishes downloading and the promise resolves, React re-renders and commits the real component in place of the fallback. If the promise has already resolved once (the user visited that route before), the chunk is cached and the fallback typically never appears.

Barrel files add one subtlety worth knowing: because an index.js re-exports everything in a folder, importing even one named export can force the bundler to evaluate the whole barrel file, including modules you didn’t need, unless your bundler’s tree-shaking is aggressive enough to prune them. This rarely matters for small feature folders, but it’s why very large shared component libraries sometimes avoid barrel files, or keep them shallow, to keep bundle sizes predictable.

Common Mistakes

Mistake 1: Deep relative imports instead of aliases

Without a path alias, importing a shared component from a deeply nested feature file looks like this:

import Button from "../../../../components/ui/Button/Button";
import formatDate from "../../../../lib/utils/formatDate";

This isn’t broken syntax, but it’s fragile: moving either file one folder deeper breaks the import, and at a glance it’s hard to tell whether the path is even correct. Configure a @ alias pointing at src/ (in vite.config.js‘s resolve.alias, or compilerOptions.paths in tsconfig.json/jsconfig.json) and imports become stable no matter how deep the importing file lives:

import Button from "@/components/ui/Button";
import formatDate from "@/lib/formatDate";

Mistake 2: Cramming everything into one file, which invites mutation shortcuts

When a whole feature lives inline in one giant component instead of an extracted, colocated hook, it becomes tempting to take shortcuts like mutating state directly:

function TodoList() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    todos.push({ id: Date.now(), text, completed: false });
    setTodos(todos);
  }

  // ...
}

push() mutates the existing array in place, so the array reference passed to setTodos is the exact same reference React already has. React’s state update compares references, sees no change, and never re-renders — the new todo silently fails to appear. Extracting this logic into its own hook (as in Example 2) makes the correct, immutable pattern the obvious one:

function addTodo(text) {
  setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
}

The spread creates a brand-new array, so React detects the change and re-renders with the new todo included.

Best Practices

  • Colocate a component with its styles, hook, and test file; only promote something to a shared folder once a second feature genuinely needs it.
  • Reserve components/ or ui/ for generic, reusable pieces (Button, Modal, Input) with no knowledge of any specific feature.
  • Configure a path alias (@/) so imports never turn into long ../../../../ chains.
  • Keep barrel files shallow and intentional — export only what other features actually need, not "everything, just in case."
  • One component per file, file name matching the component name, default export for the main component.
  • Keep App.jsx thin: providers, routes, and layout only — no data fetching or business logic.
  • Split code at page/route boundaries with lazy and Suspense so users don’t download the entire app up front.
  • Put non-component logic (API calls, formatting, validation) in lib/ or a feature’s api/ folder, not inline inside JSX event handlers.
  • Name test files consistently (TodoList.test.jsx) next to the component they test, not in a separate mirrored tree.

Practice Exercises

  • Take a single-file counter app where App.jsx contains all the state and JSX, and refactor it into a feature folder: features/counter/hooks/useCounter.js, features/counter/components/Counter.jsx, and a barrel index.js. Import Counter from App.jsx and confirm the app behaves identically.
  • Set up a path alias in a Vite project so @/features/todos resolves to src/features/todos, then rewrite one existing relative import in your project to use it instead.
  • Add lazy and Suspense around a page component that is currently imported eagerly, and confirm in your browser’s network tab that its chunk is only requested after you navigate to that route, not on initial load.

Summary

  • React has no built-in file structure convention — it’s a bundler and human-organization concern, not a runtime one.
  • Type-based structure (components/, hooks/, pages/) suits small apps; feature-based structure scales better as an app grows.
  • Colocate related files first, and only extract to a shared folder once code is reused by more than one feature.
  • Barrel files (index.js) simplify import paths but can hurt tree-shaking and cause circular imports if overused.
  • Path aliases eliminate long relative import chains and keep imports stable as files move.
  • lazy and Suspense split your bundle at the same boundaries your folder structure already implies, usually pages or routes.
  • Regardless of structure, keep state updates immutable, keep App.jsx thin, and keep business logic out of JSX event handlers.