javascriptroom guide

Best Practices for Structuring React Applications

React’s flexibility is one of its greatest strengths, but this freedom can lead to inconsistent, hard-to-maintain codebases—especially as applications grow in size and complexity. A well-structured React application isn’t just about aesthetics; it improves collaboration, scalability, and long-term maintainability. Whether you’re building a small app or a large enterprise solution, following best practices for structure ensures your code remains organized, efficient, and easy to debug. In this blog, we’ll dive into **key best practices** for structuring React applications, covering project organization, component design, state management, routing, styling, testing, and more. Each section includes rationale, examples, and actionable advice to help you implement these practices in your projects.

Table of Contents

  1. Project Organization: Folder Structure
  2. Component Structure and Composition
  3. State Management Strategies
  4. Routing Best Practices
  5. Styling Approaches
  6. Code Splitting and Performance Optimization
  7. Testing React Applications
  8. Documentation
  9. Tooling for Consistency and Quality
  10. Conclusion
  11. References

1. Project Organization: Folder Structure

A clear folder structure is the foundation of a maintainable React app. It helps developers navigate the codebase, locate files quickly, and understand how features are organized. Two popular approaches are component-based (grouping by type) and feature-based (grouping by functionality). For most large applications, feature-based organization is preferred, as it colocated related code (components, logic, tests) for a specific feature.

Feature-Based vs. Component-Based

  • Component-Based: Groups files by type (e.g., components/, hooks/, services/). Simple for small apps but becomes messy as features grow (files for a single feature spread across multiple folders).

    src/  
    ├── components/  
    │   ├── Button.jsx  
    │   ├── Navbar.jsx  
    ├── hooks/  
    │   ├── useAuth.js  
    ├── services/  
    │   ├── api.js  
  • Feature-Based: Groups files by feature (e.g., auth/, dashboard/), with shared code in shared/. Ideal for large apps, as all code for a feature lives in one place.

    src/  
    ├── features/  
    │   ├── auth/  
    │   │   ├── components/  
    │   │   │   ├── LoginForm.jsx  
    │   │   │   ├── SignupForm.jsx  
    │   │   ├── hooks/  
    │   │   │   ├── useLogin.js  
    │   │   ├── services/  
    │   │   │   ├── authApi.js  
    │   │   ├── tests/  
    │   │   │   ├── LoginForm.test.jsx  
    │   │   ├── AuthPage.jsx  
    │   │   ├── index.js  // Exports public API for the feature  
    │   ├── dashboard/  
    │   │   ├── ...  
    ├── shared/  
    │   ├── components/  // Reusable UI components (Button, Card)  
    │   ├── hooks/       // Shared hooks (useLocalStorage, useFetch)  
    │   ├── utils/       // Helper functions (formatDate, validateEmail)  
    │   ├── assets/      // Images, fonts, global CSS  
    ├── App.jsx  
    ├── index.jsx  

Key Folders in Feature-Based Structure

  • features/: Contains all feature-specific code. Each feature folder includes:

    • components/: Feature-specific components (not shared).
    • hooks/: Custom hooks for the feature.
    • services/: API calls or external service integrations.
    • tests/: Tests for the feature.
    • index.js: Exports public components/hooks (encapsulates internal files).
  • shared/: Reusable code across features (e.g., Button, useFetch, formatCurrency).

  • routes/: Route definitions (if using a centralized routing setup).

  • store/: Global state (e.g., Redux slices, Zustand stores).

Why It Matters: Reduces cognitive load by keeping related code together. New developers can quickly grasp feature boundaries, and refactoring is easier (delete a feature folder without hunting for scattered files).

2. Component Structure and Composition

React components are the building blocks of your app. Well-structured components are reusable, testable, and easy to debug.

Prefer Functional Components with Hooks

Since React 16.8, functional components with hooks have replaced class components as the standard. Hooks (e.g., useState, useEffect, useContext) simplify state management and lifecycle logic.

Example: Functional Component vs. Class Component

// ❌ Avoid: Class component (verbose, harder to reuse logic)  
class Counter extends React.Component {  
  state = { count: 0 };  
  increment = () => this.setState({ count: this.state.count + 1 });  
  render() {  
    return <button onClick={this.increment}>{this.state.count}</button>;  
  }  
}  

// ✅ Prefer: Functional component with hooks (concise, reusable logic)  
const Counter = () => {  
  const [count, setCount] = useState(0);  
  const increment = () => setCount(prev => prev + 1);  
  return <button onClick={increment}>{count}</button>;  
};  

Separation of Concerns: Presentational vs. Container Components

  • Presentational Components: Focus on UI (how things look). They accept props and render markup. Stateless (or minimal state).

    // Presentational Component (shared/Button.jsx)  
    const Button = ({ label, onClick, variant = "primary" }) => (  
      <button className={`btn btn-${variant}`} onClick={onClick}>  
        {label}  
      </button>  
    );  
  • Container Components: Focus on logic (how things work). They fetch data, manage state, and pass props to presentational components. Often use hooks or global state.

    // Container Component (features/auth/LoginPage.jsx)  
    const LoginPage = () => {  
      const [email, setEmail] = useState("");  
      const { login, isLoading } = useAuth(); // Custom hook for auth logic  
    
      const handleSubmit = (e) => {  
        e.preventDefault();  
        login(email);  
      };  
    
      return (  
        <form onSubmit={handleSubmit}>  
          <input  
            type="email"  
            value={email}  
            onChange={(e) => setEmail(e.target.value)}  
          />  
          <Button label={isLoading ? "Logging in..." : "Login"} disabled={isLoading} />  
        </form>  
      );  
    };  

Component Composition

Avoid “god components” (large, multi-purpose components). Instead, compose smaller components using:

  • Children Props: Pass nested content to a component (e.g., Card with Card.Header and Card.Body).

    const Card = ({ children }) => <div className="card">{children}</div>;  
    const CardHeader = ({ children }) => <div className="card-header">{children}</div>;  
    
    // Usage  
    <Card>  
      <CardHeader>Welcome</CardHeader>  
      <p>Hello, world!</p>  
    </Card>  
  • Compound Components: Expose a set of related components (e.g., Tabs, Tabs.List, Tabs.Panel from Radix UI) to create flexible UIs.

  • Higher-Order Components (HOCs) or Custom Hooks: Reuse logic across components (e.g., withAuth HOC for protected routes, or useAuth hook).

Avoid Prop Drilling: Passing props through multiple levels of components. Use Context API or global state (e.g., Redux) for shared state, or extract logic into custom hooks.

3. State Management Strategies

State management is critical for handling data flow in React apps. Choose the right tool based on your app’s complexity.

Local State vs. Global State

  • Local State: Use for component-specific data (e.g., form inputs, UI toggles) with useState or useReducer.

    const Form = () => {  
      const [formData, setFormData] = useState({ name: "", email: "" });  
      // ...  
    };  
  • Global State: Use for data shared across components (e.g., user auth, app theme, shopping cart). Popular tools:

    • Zustand/Jotai: Lightweight, minimal boilerplate (good for small to medium apps).
    • Redux Toolkit: Mature, battle-tested (good for large apps with complex state logic).
    • Context API: Built into React, but avoid for frequent state updates (can cause performance issues).

Best Practices for Global State

  • Normalize Data: Store complex data (e.g., from APIs) in a flat structure (like a database) for easier access and updates.

    // ❌ Avoid: Nested data (hard to update)  
    const state = {  
      users: [{ id: 1, name: "Alice", posts: [{ id: 1, title: "Hello" }] }]  
    };  
    
    // ✅ Prefer: Normalized data (easy to update a post by ID)  
    const state = {  
      users: { 1: { id: 1, name: "Alice", postIds: [1] } },  
      posts: { 1: { id: 1, title: "Hello", userId: 1 } }  
    };  
  • Minimize Global State: Only put data in global state if multiple components need it. Overusing global state bloats the store and complicates debugging.

4. Routing Best Practices

React Router is the de facto standard for routing in React apps. Organize routes logically to reflect your app’s structure.

Key Routing Practices

  • Centralize Route Definitions: Define routes in a single file (e.g., src/routes.jsx) for clarity.

    // src/routes.jsx  
    import { createBrowserRouter } from "react-router-dom";  
    import Home from "./features/home/HomePage";  
    import Login from "./features/auth/AuthPage";  
    
    export const router = createBrowserRouter([  
      { path: "/", element: <Home /> },  
      { path: "/login", element: <Login /> },  
    ]);  
  • Dynamic Routes: Use URL parameters for dynamic content (e.g., /users/:id).

    import { useParams } from "react-router-dom";  
    
    const UserProfile = () => {  
      const { id } = useParams(); // Accesses `id` from URL  
      // Fetch user data for `id`  
    };  
  • Protected Routes: Restrict access to authenticated users with a wrapper component.

    const ProtectedRoute = ({ element }) => {  
      const { isAuthenticated } = useAuth();  
      return isAuthenticated ? element : <Navigate to="/login" />;  
    };  
    
    // Usage in routes  
    { path: "/dashboard", element: <ProtectedRoute element={<Dashboard />} /> }  
  • Route-Based Code Splitting: Load components dynamically to reduce initial bundle size (see Section 6).

5. Styling Approaches

Styling in React can be done with CSS modules, styled components, or utility-first frameworks like Tailwind. The goal is to scope styles, avoid conflicts, and maintain consistency.

Scoped Styles with CSS Modules

CSS Modules (.module.css) scope styles to a component by generating unique class names, preventing global conflicts.

Example:

/* Button.module.css */  
.button {  
  padding: 8px 16px;  
  border: none;  
}  

.primary {  
  background: blue;  
  color: white;  
}  
// Button.jsx  
import styles from "./Button.module.css";  

const Button = ({ variant = "primary" }) => (  
  <button className={`${styles.button} ${styles[variant]}`}>  
    Click me  
  </button>  
);  

Utility-First with Tailwind CSS

Tailwind provides utility classes (e.g., px-4, bg-blue-500) to build UIs directly in JSX. It’s fast and enforces consistency but can make JSX verbose. Use @apply to extract repeated patterns.

Theming

Use CSS variables or styled-components’ ThemeProvider to enforce a design system (e.g., colors, spacing).

/* theme.css */  
:root {  
  --color-primary: #007bff;  
  --spacing-sm: 8px;  
}  
/* Usage */  
.button {  
  background: var(--color-primary);  
  padding: var(--spacing-sm);  
}  

Avoid Global CSS: Global styles (e.g., app.css) are hard to maintain. Use scoped styles or utility classes instead.

6. Code Splitting and Performance Optimization

Large bundle sizes slow down app load times. Code splitting and performance optimizations ensure your app is fast and responsive.

Code Splitting with React.lazy and Suspense

Load non-critical components only when needed (e.g., on route change).

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

// Dynamically import the component (loaded only when the route is visited)  
const Dashboard = lazy(() => import("./features/dashboard/Dashboard"));  

const App = () => (  
  <Router>  
    <Suspense fallback={<div>Loading...</div>}>  
      <Routes>  
        <Route path="/dashboard" element={<Dashboard />} />  
      </Routes>  
    </Suspense>  
  </Router>  
);  

Avoid Unnecessary Re-renders

  • React.memo: Memoize components to prevent re-renders if props haven’t changed.

    const UserCard = React.memo(({ user }) => <div>{user.name}</div>);  
  • useCallback/useMemo: Memoize functions and values to avoid recreating them on every render.

    const handleClick = useCallback(() => { /* ... */ }, [deps]);  
    const sortedList = useMemo(() => data.sort(), [data]);  

Optimize Bundle Size

  • Tree Shaking: Remove unused code with tools like Webpack or Vite (ensure package.json has "sideEffects": false for libraries).
  • Analyze Bundles: Use webpack-bundle-analyzer to identify large dependencies and split them.

7. Testing React Applications

Testing ensures your app works as expected and prevents regressions.

Unit and Component Tests

Use Jest (test runner) and React Testing Library (component testing) to test components in isolation.

Example: Testing a Button Component

import { render, screen, fireEvent } from "@testing-library/react";  
import Button from "./Button";  

test("calls onClick when clicked", () => {  
  const handleClick = jest.fn();  
  render(<Button label="Click" onClick={handleClick} />);  
  fireEvent.click(screen.getByText("Click"));  
  expect(handleClick).toHaveBeenCalledTimes(1);  
});  

Integration and E2E Tests

  • Integration Tests: Test interactions between components (e.g., form submission flow).
  • E2E Tests: Use Cypress or Playwright to test the app as a user would (e.g., logging in, navigating to a page).

Test Hooks and State

Test custom hooks with @testing-library/react-hooks.

import { renderHook, act } from "@testing-library/react-hooks";  
import useCounter from "./useCounter";  

test("increments count", () => {  
  const { result } = renderHook(() => useCounter());  
  act(() => result.current.increment());  
  expect(result.current.count).toBe(1);  
});  

8. Documentation

Well-documented code helps teams collaborate and maintain the app long-term.

Component Documentation with Storybook

Storybook lets you build and document components in isolation. Write stories to showcase component variants and usage.

Example: Story for a Button

// Button.stories.jsx  
import Button from "./Button";  

export default { title: "Components/Button" };  
export const Primary = () => <Button variant="primary">Primary</Button>;  
export const Secondary = () => <Button variant="secondary">Secondary</Button>;  

README Files and JSDoc

  • Feature READMEs: Explain the purpose of a feature, how to use its components, and known limitations.
  • JSDoc Comments: Document props, hooks, and functions for IDE support.
    /**  
     * A reusable button component.  
     * @param {string} label - Text to display on the button.  
     * @param {() => void} onClick - Callback when clicked.  
     */  
    const Button = ({ label, onClick }) => <button onClick={onClick}>{label}</button>;  

9. Tooling for Consistency and Quality

Tools automate code quality, formatting, and type safety, reducing bugs and ensuring consistency.

ESLint and Prettier

  • ESLint: Enforces code quality rules (e.g., no unused variables, prefer const over let).
  • Prettier: Auto-formats code (indentation, line length) to avoid bikeshedding.

Setup:

// .eslintrc.js  
module.exports = {  
  extends: ["react-app", "prettier"],  
  rules: { "react/prop-types": "error" }  
};  

TypeScript

Add type safety to catch errors early. Define interfaces for props, state, and API responses.

interface User {  
  id: number;  
  name: string;  
}  

const UserCard = ({ user }: { user: User }) => <div>{user.name}</div>;  

Husky and Pre-Commit Hooks

Run linting/tests before commits to prevent bad code from entering the repo.

# Install Husky  
npm install husky --save-dev  
npx husky install  
npx husky add .husky/pre-commit "npm run lint && npm test"  

10. Conclusion

Structuring a React application well is an investment in maintainability, scalability, and developer happiness. By following these best practices—from feature-based folder structures to code splitting and testing—you’ll build apps that are easier to debug, extend, and collaborate on.

Remember, there’s no one-size-fits-all solution. Adapt these practices to your team’s needs and app complexity, but always prioritize clarity and consistency.

11. References