A Better Way to Structure React Projects: An Expert Guide

As a full-stack developer who has worked on numerous large-scale React projects, I‘ve seen firsthand how critical a thoughtful, well-designed codebase architecture is to the long-term success and maintainability of an application. All too often, React projects that start off simple grow into complex, tangled webs of tightly-coupled components, unmanageable shared state, and spaghetti code.

The statistics paint a stark picture of how quickly React codebases can balloon in size and complexity. A recent analysis of over 4 million open source JavaScript projects found that the median project now contains over 30,000 lines of code and more than 1,000 dependencies [1]. Another study of enterprise React codebases revealed an average of 678 unique components per application, with some projects exceeding 2,000 components [2]. Without a solid architectural foundation, it‘s easy for a React codebase to devolve into an unmaintainable mess.

Fortunately, by following best practices and battle-tested design patterns, we can mitigate these risks and keep even the most complex React codebases lean, modular, and easy to reason about. Having spent the better part of a decade building and scaling React applications for startups and enterprises alike, I‘ve developed a set of guidelines for structuring React projects that have served me well across a variety of teams and domains.

In this guide, we‘ll take a deep dive into the key pillars of a maintainable React architecture, from the high-level folder structure down to the design of individual components. We‘ll explore different approaches to code organization, discuss best practices for state management and testing, and walk through concrete examples of how to apply these concepts in practice. By the end of this post, you‘ll have a solid blueprint for architecting your next React project to be scalable, performant, and a joy to work with.

Organizing Your React Project

The first critical decision to make when structuring a React project is how to organize your files and folders. While there‘s no one "right" way to do this, there are several established patterns that can help keep your codebase structured and maintainable.

One common approach is to group files by feature or route, co-locating all the components, styles, tests and other assets related to a specific part of the application together. A file structure using this approach might look like:

src/
  dashboard/
    DashboardNav.js
    DashboardNav.test.js
    DashboardNav.css
    DashboardContent.js
    DashboardContent.test.js
    DashboardContent.css
  products/
    ProductList.js
    ProductList.test.js
    ProductList.css
    ProductDetails.js
    ProductDetails.test.js
    ProductDetails.css

The benefit of this approach is that it makes it easy to find all the code related to a particular feature in one place, and enforces a separation of concerns between different parts of the application. As a project grows, you can simply add new folders for new features without affecting the existing organization.

Another popular strategy is to group files by type, putting all components together, all styles together, all tests together, etc. This is the default structure used by many React application generators and boilerplates. A project organized this way would look more like:

src/
  components/
    Dashboard/
      DashboardNav.js
      DashboardContent.js
    Products/  
      ProductList.js
      ProductDetails.js
  styles/
    Dashboard/
      DashboardNav.css
      DashboardContent.css  
    Products/
      ProductList.css
      ProductDetails.css
  tests/
    Dashboard/
      DashboardNav.test.js
      DashboardContent.test.js
    Products/
      ProductList.test.js  
      ProductDetails.test.js

Grouping files by type can make it easier to reuse components and styles across different features, and can simplify the import statements in your code. It also allows you to enforce coding standards and best practices more easily, since all files of a given type are colocated.

A third approach that has gained popularity recently is the Atomic Design methodology [3], which organizes components into atoms, molecules, organisms, templates and pages based on their level of complexity and composability. Atomic Design provides a systematic way to craft a reusable component architecture, making it easier to reason about how different parts of the UI fit together.

Ultimately, the "right" project structure will depend on the specific needs and constraints of your team and application. The most important thing is to be consistent, and to put thought into how your chosen structure will scale and evolve as the project grows.

Designing Modular, Composable React Components

Once you‘ve established a high-level structure for your project, the next critical consideration is how you design and implement the actual React components that make up your application.

The key principle to keep in mind here is modularity. A well-designed React component should be self-contained, reusable, and focused on doing one thing well. It should have a clear, well-defined interface for receiving props and managing its own internal state, and should avoid making assumptions about its parent components or the global application state.

Some specific tactics for keeping your React components modular and maintainable:

  • Keep components small and single-responsibility. If a component is doing too many things or getting too large, consider breaking it down into smaller sub-components.

  • Use function components and hooks instead of class components. Function components are simpler, more reusable, and avoid issues related to this bindings [4].

  • Keep components pure and deterministic whenever possible. Pure components always return the same output for a given set of props, which makes them easier to reason about and test. Avoiding side effects and mutations also helps prevent subtle bugs.

  • Use PropTypes or TypeScript to define and validate the props expected by each component. This serves as built-in documentation and can catch many common errors.

  • Memoize expensive computations and callbacks to avoid unnecessary re-renders. React‘s useMemo and useCallback hooks make this easy [5].

  • Consider using React.lazy and Suspense for loading components on demand, which can significantly improve the performance of larger applications [6].

Here‘s an example of a simple, modular React component that follows these best practices:

import React from ‘react‘;
import PropTypes from ‘prop-types‘;

function Button({ children, onClick, disabled, variant }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`button button--${variant}`}
    >
      {children}
    </button>
  );
}

Button.propTypes = {
  children: PropTypes.node.isRequired,
  onClick: PropTypes.func.isRequired,
  disabled: PropTypes.bool,
  variant: PropTypes.oneOf([‘primary‘, ‘secondary‘]),
};

Button.defaultProps = {
  disabled: false,
  variant: ‘primary‘,
};

export default React.memo(Button);

This Button component is focused and reusable, expecting a clear set of props and encapsulating its own styling and behavior. It uses propTypes to document its expected props, and defaultProps to set sensible defaults. The component is memoized with React.memo to avoid unnecessary re-renders when its props don‘t change.

By building our applications out of these kinds of small, modular components, we can create an architecture that is more flexible, maintainable, and testable over time.

Managing State in a Complex React Application

Perhaps the most complex and error-prone part of any non-trivial React application is managing state. As the number of components and interactions grows, it becomes increasingly difficult to reason about how data should flow through the component tree and how to keep everything in sync.

There are several common approaches to managing state in a React application, each with their own tradeoffs and best practices.

For simpler applications, using React‘s built-in component state and passing props down the tree can be sufficient. However, this can quickly lead to prop drilling and make it difficult to share state between distant components in the tree.

For more complex scenarios, a global state management solution like Redux or MobX can help. These libraries provide a centralized store for managing application state, along with patterns like actions and reducers for updating that state in a predictable way.

Here‘s a simple example of managing state in a React component using Redux:

import React from ‘react‘;
import { useSelector, useDispatch } from ‘react-redux‘;
import { increment, decrement } from ‘./counterSlice‘;

function Counter() {
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
    </div>
  );
}

export default Counter;

In this example, the Counter component reads its state from the Redux store using the useSelector hook, and dispatches actions to update that state using the useDispatch hook. The actual state management logic is encapsulated in a separate counterSlice file, keeping the component focused on presentation.

More recently, libraries like Recoil [7] and Zustand [8] have emerged as more lightweight alternatives to Redux, providing a simpler API for managing global state with less boilerplate.

Ultimately, the key to managing state effectively in a large React application is to be intentional about what state lives where, and to encapsulate state management logic in a consistent, predictable way. By centralizing state management in a library like Redux or Recoil, we can make our components more focused and reusable, and make it easier to reason about how data flows through our application.

Testing and Maintaining a React Codebase

As a React project grows in size and complexity, automated testing becomes increasingly critical for catching bugs, preventing regressions, and enabling refactoring with confidence.

A typical React testing strategy involves a combination of unit tests for individual components and functions, integration tests for testing the interactions between components, and end-to-end tests for testing complete user flows in a real browser.

At the unit testing level, libraries like Jest [9] and React Testing Library [10] have become the de facto standards for testing React components in isolation. These tools provide a simple, declarative API for rendering components, simulating user interactions, and making assertions about the rendered output.

Here‘s an example of a simple unit test for our Button component using React Testing Library:

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

describe(‘Button‘, () => {
  test(‘renders the correct children‘, () => {
    const { getByText } = render(<Button>Hello</Button>);
    expect(getByText(‘Hello‘)).toBeInTheDocument();
  });

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

  test(‘is disabled when the disabled prop is true‘, () => {
    const { getByText } = render(<Button disabled>Disabled</Button>);
    expect(getByText(‘Disabled‘)).toBeDisabled();
  });
});

This test suite verifies that the Button component renders the correct children, calls the onClick handler when clicked, and is disabled when the disabled prop is true. By writing comprehensive unit tests for our components, we can catch bugs early and ensure that our components continue to work as expected as we make changes to the codebase.

At the integration testing level, tools like Enzyme [11] and Cypress [12] allow us to test how multiple components interact and behave together. These tests are more complex and slower to run than unit tests, but are essential for catching bugs that only emerge when components are combined.

Finally, end-to-end testing tools like Puppeteer [13] and WebdriverIO [14] enable us to test our application in a real browser, simulating complete user flows and verifying that the application behaves correctly from the user‘s perspective.

By combining these different types of tests and running them frequently, we can maintain a high level of confidence in the quality and correctness of our React codebase, even as it grows and evolves over time.

Conclusion

Building and maintaining a complex React application is no small feat, but by following best practices and investing in a thoughtful, scalable architecture, we can keep our codebases manageable and maintainable for the long haul.

The key principles to keep in mind are:

  1. Structure your project in a consistent, logical way that will scale well as the codebase grows.
  2. Design your components to be modular, composable, and focused on doing one thing well.
  3. Be intentional about how you manage state, and use a centralized state management solution for complex applications.
  4. Test your codebase at multiple levels, from individual components up to complete user flows.
  5. Continuously refactor and pay down technical debt to keep your codebase lean and maintainable.

By building these principles into your development process from the start, you‘ll be well-equipped to tackle even the most ambitious React projects with confidence. Happy coding!

References

[1] https://blog.appsignal.com/2020/04/08/ride-down-the-javascript-dependency-hell.html
[2] https://www.wpoven.com/blog/react-js-best-practices-and-security-for-2021
[3] https://bradfrost.com/blog/post/atomic-web-design/
[4] https://reactjs.org/docs/hooks-intro.html#motivation
[5] https://reactjs.org/docs/hooks-reference.html#usememo
[6] https://reactjs.org/docs/code-splitting.html#reactlazy
[7] https://recoiljs.org/
[8] https://github.com/pmndrs/zustand
[9] https://jestjs.io/
[10] https://testing-library.com/docs/react-testing-library/intro/
[11] https://enzymejs.github.io/enzyme/
[12] https://www.cypress.io/
[13] https://pptr.dev/
[14] https://webdriver.io/

Similar Posts