Things I learned From Reading the Book “The Dao of React” Part 1: Architecture

abdul ahad
8 min read5 days ago

--

Photo by Shubham Dhage on Unsplash

Things to care about when scaling react architecture

- Create a Common Module

One of the first principles of good architecture is to organize your reusable logic. In React, many parts of the codebase — such as UI components, custom hooks, utility functions, and constants — are used across multiple parts of the application. Rather than duplicating these pieces of code, it’s a best practice to place them in a common module. This module serves as a centralized location for shared code, making your app easier to maintain and extend.

  • Example: Imagine a form that needs a Button component across multiple pages. Instead of redefining the button each time, you can create it once in a common module.
src/
├── common/
│ ├── components/
│ │ ├── Button.jsx
│ ├── hooks/
│ │ └── useFetch.js
├── pages/
│ ├── HomePage.jsx
│ └── ProfilePage.jsx
  • Now, both HomePage and ProfilePage can import and use the Button and useFetch hooks from the common module.

- Use Absolute Paths

As projects grow, managing relative import paths becomes difficult and error-prone. Switching to absolute paths makes it easier to refactor and move files around without breaking imports.

  • Before (relative paths):
import Button from '../../../common/components/Button';

After (absolute paths):

import Button from '@common/components/Button';
  • You can configure this by setting up module resolution in your bundler (e.g., Webpack) or in tools like tsconfig.json for TypeScript. This way, your imports are more readable and less prone to error when restructuring your app.

- Put Components in Folders

Large components often come with multiple associated files, such as stylesheets, tests, and subcomponents. A cleaner way to organize these is by placing each component in its own folder, ensuring that everything related to a component is grouped together.

  • Example: Instead of cluttering your project with scattered files, nest them within folders named after the component.
src/
├── components/
│ ├── Header/
│ │ ├── Header.jsx
│ │ ├── Header.test.jsx
│ │ ├── Header.styles.js
│ ├── Footer/
│ │ ├── Footer.jsx
│ │ └── Footer.styles.js
  • This approach makes it easier to navigate your project and ensures that each component is self-contained and modular.

- Group Components by Route/Module

Instead of using the traditional “components” and “containers” folder structure, a more scalable approach is to group your components by routes or features. This organizes your app based on functionality rather than types of files, which makes navigating and maintaining the project more intuitive.

  • Example: If your app has a dashboard and a profile page, group all the components, hooks, and utilities for each route under its respective folder.
src/
├── dashboard/
│ ├── components/
│ ├── hooks/
├── profile/
│ ├── components/
│ ├── utils/
  • This modular approach keeps related code close together, reducing the complexity of larger applications.

- Manage Dependencies Between Modules

As applications grow, you’ll inevitably need to share components or logic between modules. While it’s tempting to directly import from one module to another, it’s better to keep modules decoupled to maintain flexibility and avoid tight coupling.

  • Example: Instead of importing a Graph component from a Dashboard module into a Profile module, move the Graph component to the common module if it's used in multiple places. This ensures that shared logic is maintained in one place.
src/
├── common/
│ ├── components/
│ │ ├── Graph.jsx
├── dashboard/
├── profile/
  • Following this practice prevents your codebase from becoming interdependent and makes it easier to maintain and refactor.

- Wrap External Components

It’s common to use third-party libraries in your React projects. However, coupling your internal code to external libraries can lead to problems when those libraries change. To protect your codebase, wrap third-party components in your own components.

  • Example: If you’re using a DatePicker component from a third-party library, wrap it in your own component before using it.
// components/DatePickerWrapper.jsx
import React from 'react';
import DatePicker from 'react-datepicker';

const DatePickerWrapper = (props) => {
return <DatePicker {...props} />;
};

export default DatePickerWrapper;
  • Now, if you ever switch libraries, you’ll only need to update the wrapper rather than every instance of DatePicker in your app.

- How to Design a Module

When designing a module, think of it as a self-contained unit that represents a specific feature or functionality. Each module should contain its own components, hooks, utilities, and even its own routing logic if necessary.

  • Example: A Dashboard module may contain components like Chart, Sidebar, and Stats, as well as hooks and utilities that are specific to the dashboard feature.
src/
├── dashboard/
│ ├── components/
│ ├── hooks/
│ ├── utils/
  • Each module should be designed to minimize dependencies on other parts of the codebase, allowing you to scale and extend functionality more easily.

- Project Structure Should Tell a Story

Your application’s structure should reflect its functionality. When another developer (or your future self) looks at the folder structure, they should be able to quickly understand what the application does and where to find specific features.

  • Example: Instead of generic folder names like components, opt for feature-based or domain-specific names. For example, if your app has a shopping cart, create a cart module rather than scattering cart-related logic across multiple folders.
src/
├── cart/
│ ├── CartPage.jsx
│ ├── CartItem.jsx
│ └── cartUtils.js
  • This tells the story of the app: “Here’s where the shopping cart functionality lives.”

- Keep Things Close to Where They’re Used

If a utility function, hook, or style is only used by a single component, it should live close to that component. This reduces the cognitive load of tracking down where things are defined and used.

  • Example: Place utility functions or hooks directly in the component folder if they are not shared across the app.
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── useButtonStyles.js
  • If you find that multiple components need the same logic, only then move it to a more global location like common/hooks.

- Don’t Put Business Logic in Utility Functions

Utility functions should be generic and reusable across different parts of the application. Avoid embedding business logic in utility functions to keep them versatile.

  • Example: Instead of writing a specific function for sorting articles, create a general sorting utility.
/// Generic utility function
function sortByAttribute(array, attribute) {
return array.sort((a, b) => a[attribute] - b[attribute]);
}
  • This makes the function reusable for other sorting tasks beyond articles.

- Separate Business Logic From UI

React components should primarily handle rendering the UI and should not be responsible for handling complex business logic, such as data fetching or input validation. A common practice is to extract such logic into custom hooks, making the components more readable and maintainable.

  • Example
function useFetchData(url) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(response => response.json()).then(data => setData(data));
}, [url]);
return data;
}

function MyComponent() {
const data = useFetchData('/api/data');
return <div>{data ? 'Data Loaded' : 'Loading...'}</div>;
}

Modules Should Own Their Routes

Each module of your application should manage its own routing structure instead of declaring all routes in a global file. This decouples the routing logic, making each module responsible for its own routing, which in turn helps prevent route-related errors in other parts of the application.

  • Example: Keep the routes for a dashboard module inside its own module file rather than a global App.js.
function DashboardModule() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/stats" element={<Stats />} />
</Routes>
);
}

- Avoid Single Global Contexts

While React’s Context API solves prop drilling issues, using a single global context to manage all state can lead to a bloated and slow application. Instead, break contexts into smaller, more manageable ones and scope them according to their use case.

  • Example: Instead of a single global context, create separate contexts:
const ThemeContext = React.createContext();
const AuthContext = React.createContext();
  • Use these contexts only where they are necessary.

- Shared Libraries and Design Systems

When working on a team, it’s common to extract reusable components into a shared library. This allows other developers to easily import the same component across different projects or parts of the app, improving consistency and reducing duplicated efforts. However, keep in mind that building such a library requires considerable maintenance.

  • Example: A company-wide design system might include common components like buttons, form elements, and typography that follow the same style guidelines.

- Run the Application with a Single Command

Simplify the developer onboarding process by ensuring that your application can be started with a single command like npm run dev. This reduces the friction for new team members to get the app up and running, especially if complex setups are required.

  • Example: Ensure commands like npm run dev cover all necessary setup steps such as loading environment variables, starting Docker containers, or compiling assets.

- Pin Dependency Versions

It’s crucial to pin dependency versions, even minor ones, to avoid unpredictable changes that could break your application. Dependencies, especially third-party libraries, may introduce bugs or breaking changes in new releases, so specifying exact versions ensures consistent behavior.

  • Example: Use "react": "17.0.2" rather than "react": "^17.0.0". This locks the version so you avoid potential issues caused by updates you haven’t vetted.

- The Styling Dilemma

Choosing a styling method for your React application (CSS, CSS Modules, CSS-in-JS, utility-first libraries like Tailwind) depends largely on the needs of the project and team preferences. The key is to pick one that aligns with your development goals while keeping the complexity low.

Example:

  • CSS-in-JS: Components include their styles inline or with libraries like Styled Components.
  • Tailwind: A utility-first CSS framework for fast styling without writing custom CSS files.

- Avoid Hardcoding Links

Instead of hardcoding URLs and links in your React application, create a central place (like a routes.js file) to store these paths. This makes it easier to update paths globally and ensures consistency across the app.

  • Example:
const routes = {
home: '/',
dashboard: '/dashboard',
};

<Link to={routes.dashboard}>Go to Dashboard</Link>

Use TypeScript

TypeScript has become a popular choice in React applications because it enhances JavaScript with static typing. This makes your code more predictable and less prone to bugs, as the TypeScript compiler can catch issues before they make it into your application.

  • Example: By typing props for a component, you can ensure that only the correct types are passed.
interface ButtonProps {
label: string;
onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
<button onClick={onClick}>{label}</button>
);

In this example, TypeScript ensures that label is always a string and onClick is always a function, making the component safer and easier to use.

Create Layout Components

Layout components are high-level components responsible for the overall structure of your page, such as headers, footers, sidebars, and containers. Separating layout from the content components makes your app more modular and reusable.

  • Example:
const Layout = ({ children }) => (
<div>
<Header />
<Sidebar />
<main>{children}</main>
<Footer />
</div>
);

const HomePage = () => (
<Layout>
<p>This is the home page content.</p>
</Layout>
);

Here, the Layout component is used to wrap the content of each page, ensuring consistent structure across the app.

Don’t Wrap Context Providers

Avoid wrapping too many components in context providers directly within your App.js. This can lead to unnecessary re-renders and make the component tree harder to manage. Instead, use context providers where they're truly necessary and avoid overusing them.

  • Example: Instead of wrapping your entire app with multiple contexts:
const App = () => (
<ThemeContext.Provider>
<AuthContext.Provider>
<YourComponent />
</AuthContext.Provider>
</ThemeContext.Provider>
);

Consider wrapping only the relevant components at a lower level, closer to where the data is used:

const Page = () => (
<AuthContext.Provider>
<Dashboard />
</AuthContext.Provider>
);

Summary

By following these architectural principles, you’ll be able to create React applications that are scalable, maintainable, and easy to extend. Each concept discussed here forms the foundation of a well-structured React project. As your application grows, these practices will help you manage complexity, maintain a clean codebase, and ensure your project remains flexible enough to accommodate future changes. For a complete detail guide please checkout the book below.

--

--

abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning