A good project architecture can have a huge impact on how successful a project is in terms of understanding the codebase, flexibility and maintenance. Projects that are not well structured and maintained can quickly become a big mess and dreadful legacy that no one is too happy to work with. I have seen and worked with many different project architectures while working with various tools like React, Vue, Backbone, and even jQuery in the past. One popular approach I've seen quite often in large applications is the module based pattern. In this pattern, the code for each feature is grouped in separate modules. Suppose you need to create login and register pages. You could add register and login pages in the src/views folder and then create a user module with folders such as views, components, routes, api, store, etc., in the src/modules directory. The module-based pattern can be a good way to make your projects easier to manage. However, there are two significant problems with this pattern.
The first issue is concerned with having only files for global pages in src/views directory and everything else in src/modules. This approach encourages thinking and the perception that an application is just a set of global pages. It forces one to one relationship between a URL and a component handling that specific page. To be fair, this was the case in the past, but nowadays, a highly dynamic application can contain nested sub-views and features that can be sophisticated in their own manner. Other things to consider are mobile devices. With the boom of smartphones, responsive websites became ubiquitous. The design and development approach even shifted from desktop-first to mobile-first, as more users access the internet via mobile devices. In mobile apps, there is no association between a URL and a screen/view. Now, let's add cross-platform frameworks like Quasar/Ionic, and PWAs to the mix, which allows users to run web applications like they were regular mobile apps. If we consider all of these, we should not think that an application is just a collection of global pages but rather entry points that can have their own sophisticated sub-views. Hence, I don't think there is a need to have modules folder. Instead, we can nest sub-views in the src/views directory. I explain the reasoning behind nested sub-views in more detail later in this chapter in the Encapsulating components and business logic section.
The second problem revolves around having api folders in each module. I considered the pros and cons of it and decided it's better to have it outside. I think that an agnostic and de-coupled API layer is more flexible, reusable, and informative, as it reflects what kind of endpoints/data are available for the client. Let's take a user endpoint as an example. After a login or register action, we would need to fetch the user's details. Where should this API method be placed? It could be placed in the user module. Suppose you also have a profile or settings page that needs to fetch users' details to have the latest information. Do they both go into the user module as well? Or would they be placed in their own individual modules, but the API logic would be duplicated across different modules? I believe API methods should not belong to any specific features but rather be consumed by them, especially since API methods could be used not only in components but also hooks, services, Redux or Zustand stores, and so on. That's why I recommend having an API layer with methods that perform API requests. The API layer is covered in-depth in the API Layer and Managing Async Operations chapter of the full book.
I will now show you architecture which I very often use in my projects, and explain the reasoning behind it. This architecture should be a good starting point for a large-scale application, and you can expand on it depending on the project's needs. Here is the src structure I can recommend for most projects:
src
|-- api
|-- assets
|-- fonts
|-- images
|-- components
|-- common
|-- button
|-- Button.tsx
|-- form
|-- TextField.tsx
|-- FieldLabel.tsx
|-- text
|-- Typography.tsx
|-- Headline.tsx
|-- transitions
|-- hooks
|-- context
|-- layout
|-- config
|-- constants
|-- helpers
|-- intl (optional)
|-- services
|-- store
|-- styles
|-- types
|-- views
Let's cover the folders from top to bottom.
api
First, we have the api folder, which will contain the API Layer of our application. It will have methods that are responsible for performing API requests and communicating with a server. The API layer is covered in detail in the API Layer chapter of the full book.
assets
The assets folder contains fonts and images. In the fonts, you can keep any custom fonts and typefaces. In images store any pictures used throughout the application.
components
The only initial directory in this example is common. The common directory will contain any reusable components that are commonly used throughout the application. For instance, buttons, form components, components related to typography, and so on. Any components that are not as common would be placed inside of components but outside of the common directory.
hooks
The hooks directory, as the name suggests, would hold any custom and reusable hooks. Note that any hooks that are not really reusable, but are coupled to a specific feature, should be placed in the same directory as that feature. For instance, imagine we have a newsletter form component that contains a form to sign up a user for a newsletter. This component could utilise a hook called useNewsletterSignup that would handle signing up a user. A hook like this shouldn't be placed in the global src/hooks directory, but rather locally, as it is coupled to the NewsletterForm component. Here's what it could look like:
src
|-- hooks
|-- components
|-- common
|-- NewsletterForm
|-- hooks
|-- useNewsletterSignup.ts
|-- NewsletterForm.tsx
It's best to keep logic that is coupled as closely as possible to where it is used. This way, we will not unnecessarily add more code into the global hooks folder that should contain only reusable hooks. The same applies to other functionality, such as helpers, services, etc.
context
The context directory should contain any global-level context state providers. The Context State Provider pattern is covered in the State Management Patterns in React Apps chapter of the full book.
layout
Layout directory, as the name suggests, should have components that provide different layouts for your pages. For example, if you are building a dashboard application, you could render different layouts depending on if a user is logged in or not. This topic and how to approach layout management is covered in the Managing Application Layout chapter of the full book.
config
In the config directory, you can put any runtime config files for your application and third-party services. For instance, if you use a service like Firebase or OIDC for authentication, you will need to add configuration files and use them in your app. Just make sure not to confuse config with environmental variables, as anything that goes here will be present in the build bundle.
constants
Here you can put any constant variables that are used throughout the application. It's a good practice to capitalise your constants to distinguish them from other variables and localised constants in your app.
Below are some examples of defining and using constants.
Define constants separately
// in constants/appConstants.ts
export const APP_NAME = 'Super app'
export const WIDGETS_LABEL = 'Widgets'
// Somewhere in your app
import { APP_NAME, WIDGETS_LABEL } from '@/constants/appConstants'
console.log(APP_NAME)
// You can also grab all named exports from the file
import * as APP_CONSTANTS from '@/constants/appConstants'
console.log(APP_CONSTANTS.WIDGETS_LABEL)
Define related constants in one object
// in constants/appConstants.ts
// Create an object with constant values
export const apiStatus = {
IDLE: 'IDLE',
PENDING: 'PENDING',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR'
}
// Somewhere in your app
import { apiStatus } from '@/constants/appConstants'
console.log(apiStatus.PENDING)
helpers
Any utilities and small reusable functions should go here - for example, functions to format date, time, etc.
intl (optional)
This directory is optional. Add it if an application requires internalisation support. Intl, also known as i18n, is about displaying the content of an app in a format appropriate to the user's locale. This content can include but not be limited to translated text or specific format of dates, time, and currency. For instance, whilst the UK uses DD/MM/YYYY format, the US uses MM/DD/YYYY.
services
In larger applications, we might have complex business logic code that is used in a few different places. A code like this is a good candidate to be extracted from components and placed somewhere else, and the services folder is a good candidate for that.
store
The store folder is responsible for files related to global state management. There are many state management solutions that can be used for React projects, such as Redux, Zustand, Jotai, and many many more. Redux is covered in the Global State Management with Modern Redux chapter of the full book, and Zustand & Jotai in the Global State Management with Zustand & Jotai chapter.
styles
You can put global styles, variables, theme styles, and overrides in the styles folder.
types
Here you can put any global and shareable types.
views
Usually, the views directory only contains route/page components. For example, if we have a page that is supposed to allow users to view products, we would have a component Products.tsx in the views folder, and the corresponding route could be something like this:
<Route path="/projects" element={<Products />} />
There is a reason why I said "usually", though. Many applications have route components in the views, and the rest of the components for it are placed in the components folder. This approach can work for small to medium applications but is much harder to manage and maintain when the number of pages and components grows. The next two sections show a different approach that should make managing large-scale applications much easier.
Managing route components by feature
Code along with this section
To follow code examples in this section, switch to the
chapter/scalable-project-architecture/managing-route-components-startbranch.The final code for this section is available on the
chapter/scalable-project-architecture/managing-route-components-finalbranch.After switching a branch, run
npm installto install all dependencies.
In the views folder example mentioned above, we have a Products view. Imagine you are working on an admin dashboard for an e-commerce app. A user should be able to browse products, select a product to see more details about it, as well as add, update, and delete it. The question is, how all of this should be handled? The first thought might be to do it in the same way as we added the Products view.
<Route path="/products" element={<Products />} />
<Route path="/product/:id" element={<ViewProduct />} />
<Route path="/add-product" element={<AddProduct />} />
<Route path="/edit-product/:id" element={<EditProduct />} />
<Route path="/delete-product/:id" element={<DeleteProduct />} />
Our views directory would now contain:
views
|-- Products.tsx
|-- ViewProduct.tsx
|-- AddProduct.tsx
|-- EditProduct.tsx
|-- DeleteProduct.tsx
Just for the product feature, we have five new files. Now imagine we have ten or more features that require CRUD functionality. We could quickly end up with a massive amount of files, and it would soon become a pain to manage. Therefore, let's do it differently. Instead of keeping Product files at the top of the views folder, we will group them by a feature name. Let's put all the files in the folder called products.
views
|-- products
|-- Products.tsx
|-- ViewProduct.tsx
|-- AddProduct.tsx
|-- EditProduct.tsx
|-- DeleteProduct.tsx
All files that are related to the product feature are now kept together. Thanks to that, finding and managing route components should be much easier. We can also change routes config to follow a similar pattern and define product-related routes as children of the products path.
<Route path="/products">
<Route index element={<Products />} />
<Route path="add" element={<AddProduct />}
<Route path=":id/edit" element={<EditProduct />} />
<Route path=":id/delete" element={<DeleteProduct />} />
<Route path=":id" element={<ViewProduct />} />
</Route>
As shown in the example above, we don't use any component for the /products path but instead nest all the product routes. Here is an example of which component would be rendered for provided URLs:
- /products -
<Products /> - /products/add -
<AddProduct /> - /products/1/edit -
<EditProduct /> - /products/1/delete -
<DeleteProduct /> - /products/1 -
<ViewProduct />
Here is an example of how Link components could look like for each path.
<Link to="/products">Browse Products</Link>
<Link to="/products/2">View Product</Link>
<Link to="/products/add">Add Product</Link>
<Link to="/products/2/edit">Edit Product</Link>
<Link to="/products/2/delete">Delete Product</Link>
Note that the number 2 in the paths is just hardcoded and stands for a product id. Typically, a dynamic value should be interpolated:
<Link to={`/products/${product.id}/edit`}>Edit Product</Link>
Instead of a product ID, you could also use a slug for SEO benefits, as a slug created from a product title is more meaningful than ID numbers.
Encapsulating components and business logic
We discussed how to handle route components for the product feature, but this is not the end. How do we handle components that are used in the route components? Let's take AddProduct.tsx and EditProduct.tsx as an example. Both will need a form component to allow a user to enter product details. Let's assume that forms on both pages are so similar that we can reuse one instead of creating two form components. But where do we put the ProductForm.tsx file? Besides, let's say that the ProductForm.tsx file will require some utility functions and a service file to contain business logic. The first thought might be to put these files based on what they contain. Therefore, ProductForm.tsx would land in the components folder, as it would be used on more than one page, productFormService.ts in the services folder, and utility functions in the helpers folder. We would end up with this setup:
src
|-- components
|-- common
|-- products
|-- ProductForm.tsx
|-- services
|-- productFormService.ts
|-- helpers
|-- productFormUtils.ts
|-- views
|-- products
|-- Products.tsx
|-- ViewProduct.tsx
|-- AddProduct.tsx
|-- EditProduct.tsx
|-- DeleteProduct.tsx
It doesn't look that bad if you have just one feature in your application. However, there are a few problems with this approach. First of all, we just created a few files and put them in different folders, and with more features and files, the project will get messy very quickly. When working on a feature, especially in a team, you own this feature and code and are responsible for it. You might think that the code you are writing, be it a component or a service, might be shared and used for some other feature in the future. But the truth is, if you are working in a team on a large project, there is a very high chance that it will not. Other team members might not know that your code even exists, or it might require too many changes to be reused for something else. Therefore, instead of spreading files around the project, keep them as close together as possible - in the feature directory.
Below is an example folder structure.
src
|-- views
|-- products
|-- components
|-- productForm
|-- ProductForm.tsx
|-- productFormService.ts
|-- productFormUtils.ts
|-- helpers
|-- productUtils.ts
|-- services
|-- productService.ts
|-- Products.tsx
|-- ViewProduct.tsx
|-- AddProduct.tsx
|-- EditProduct.tsx
|-- DeleteProduct.tsx
As you can see, the product form is now encapsulated in its own directory and can be imported by any of the "products" components. Besides service and utils files for the product form, there are also utilities and services for product pages overall. Since none of these would be used anywhere else in the application, src/components, src/services, and src/helpers are not polluted with unnecessary files. Furthermore, if some of the feature pages would get more complicated, they can also be put in their own directories.
src
|-- views
|-- products
|-- ViewProduct
|-- components
|-- ProductImage.tsx
|-- ProductDetails.tsx
|-- views
|-- BasicProductDetails.tsx
|-- AdvancedProductDetails.tsx
|-- ViewProduct.tsx
You can add other directories such as hooks or context as well if required.
Summary
When a project grows in size, it might get harder to maintain it and keep track of all views, components, services, and so on. Good architecture can help a lot in making a project easier to understand, follow, and scale. The feature-based approach can improve project structure and consistency a lot because components and files are encapsulated and kept close to where they are used. Thanks to that, there is no need to jump around different folders to find related files.
