Here is some of my advice for creating good architecture for React app based on my work in multiple, different-scale commercial projects.
1. Separate logic from views
It’s widespread to see developers putting a lot of logic inside components. It’s much better to create separate files for logic functions and use them in your component. If they need the internal state of a component or any React function like setState, just provide it through arguments.
The logic of your component that you can separate are:
- State selectors (Redux) or computing any value based on a state.
- Actions performed in event handlers.
- Some actions being performed during the component lifecycle.
Of course, you have to do this with some common sense. If actions are simple one-liners, you probably won’t gain anything. However, when logic gets more complicated, think about it.
Some advantages of separating logic:
- It’s easier to unit test functions outside of components. You don’t need to render the whole component and perform user events just to test any logic. It may be suitable for integration testing, but unit tests should be all about simplicity.
- You can reuse that logic in other components. I’ve often encountered the situation where multiple components were using the same Redux selectors.
- Component files are getting smaller, which means it’s easier to maintain them. No one likes files with lots of code.
2. One component, one directory, one name
Keep a component and all related files to it in one directory. Don’t keep other components in the same directory. Name the directory the same as the component. It may also be good to start the file names with the same name as the component to show their relation. It may sound a bit Angular-like, which you probably don’t like, but it makes things simple. Just consider the following file/directory structure:
TodoList/ <- component directory
TodoList.js <- component file
TodoList.logic.js <- file with separated component’s logic as in the previous paragraph
TodoList.context.js <- file with context used by TodoList and its children
TodoList.test.js <- file with unit/integration tests
TodoList.module.css <- file with CSS modules styles (can be named *.styles.css for traditional stylesheets or *.styles.js for styled-components)
TodoListEntry/ <- directory of a child component
3. Use SOLID principles
SOLID has gained its popularity as a basis for good object-oriented programming. However, we should consider it more generally as a set of acceptable practices, no matter what paradigm you’re using. You can apply SOLID even if you’re writing functional React in Redux reducers, and even some parts of it in stylesheets. Here are some examples of SOLID applied to React:
- Split your components into multiple, specialized ones. For example, if you have an uploader with a files list, split the uploader and files list into separate parts and combine them inside one container. If in the files list you have buttons for manipulating uploaded images, likeremoving, rotating, editing, etc., split each one of them into separate components. One component should have one responsibility, or more precisely, a single business logic behind it. It implements S and O from SOLID.
- Use composition for creating more specialized versions of a component. For example, you may have a basic button and a button for asynchronous actions with a built-in loader. Instead of doing one button component parametrized by many properties or two independent, do a basic implementation called Button and a more specialized AsyncButton built on top of Button. That way, when you change the Button, AsyncButton will also get these changes, but changes specific for AsyncButton won’t affect the basic one. It implements O and L from SOLID.
- Create small, domain-specific contexts, stores inMobX, or reducers inRedux. In terms of contexts, use useReducer with a specific set of actions or expose multiple action functions instead of one general setState. It implements I from SOLID.
- When there’s no typical dependency injection in React, what’s most often connected to D from SOLID, we can apply this principle in other ways. The most obvious is having shared components taking a part of the state, along with setters, via properties. We only need to specify the contract that this state should fulfill and what can be done, e.g., with propTypes or TypeScript types.
4. Split project by routes
This is very popular but not commonly applied advice. Especially in a larger project with many routes, it’s just easier to navigate the codebase done this way. It’s all about splitting components into directories named by routes and where they are used. Also, we keep a special set of shared ones in a separate directory. A sample project structure (assuming routes “/,” “/books,” “/books/add,” “/users”) would look like this:
main.js <- root file of React project
/Books <- directory for “/books” route
/Add <- directory for “/books/add” route
/Users <- directory for “/users” route
/Root <- directory for “/” route
/shared <- directory for components shared between routes
5. Use proper tools for easier cooperation
When designing the app’s architecture, you’re most likely also deciding what tools you’ll use despite React. That’s very crucial because changing those tools later may be challenging and costly. Here, I’d like to primarily focus on tools that can make cooperation in a project more comfortable, especially for large-scale ones:
- Use storybook for documenting shared components. When the project grows in terms of codebase and developers, it makes finding proper components much more effortless.
- Configure ESLint and Prettier. You can easily enforce your code guidelines through it without any need to write them down. It’s also more comfortable for new developers to just run automatic lint fixes than going through documentation for guidelines. On the other hand, it makes code reviews easier for other developers because they don’t need to check if the new code complies with the guidelines. There are many rules and plugins that you can use to check for nearly every aspect of the code. By the way, don’t use TSLint with TypeScript because ESLint works with TS now.
- Configure unit tests. Even when you say, “I don’t have time to write tests,” most likely you’ll never find more time for it than at the beginning of the project. Even when you don’t plan to do 100% coverage, it’s good to have them configured. You never know when you’d like to test some logic without setting it live. Decide about the tools at the beginning. For example, you wouldn’t want to migrate from Enzyme to Testing Library, and keeping both of them would make an unnecessary mess.
There are a lot of things to consider when you start a new React project. Having good architecture from the start with well-planned code can simplify cooperation a lot. Even when it takes more time in the beginning, it’ll pay off in less time spent later. These five tips are, in my opinion, the most essential that I would give to anyone asking me what to consider when starting a project. Feel free to apply them in your future work.