React best practices-React at scale (Part 1)
This article outlines a set of best practices that will help with the development of large react applications.
Background
For the past 3.5 years I have been working on a React project, which has now grown into a very large project with thousands of components and a lot more lines of codes. Over the years it went through several rounds of refactoring with lots of new architectural decisions and updated to the latest trends (e.g. react hooks).
This article uses the knowledge of the past years to outline some of the best practices in React.
We establish best practices to improve code quality but more importantly to minimize the decisions we have to take.
If we have to make new decisions all the time (e.g. naming conventions, file architecture e.t.c) we are increasing the time we spent thinking and if we are not careful the complexity of the project. As developers we have a limited bandwidth and we want to use as much of it to write meaningful code that will have a positive impact for the product and our customers. And to achieve this we need best practices.
1. Think in components
One thing that hasn’t change in the React ecosystem and will probably not change any time soon is the concept of components. Components have been a great way to encapsulate logic and that’s why we should strive for small, focused components which are easy to reason about, test and reuse.
For best results we should delegate any business logic into a nested component if possible. The deeper in the components tree the logic lives the better.
Take for example the following component, which uses a translation hook to translate a piece of text inside.
We could delegate the translation business logic into another component like so:
Then if we want to translate a piece of text we can use the Translation
component:
Here are the benefits we get from this refactoring:
- We managed to abstract away the logic for translation into the
Translation
component. Now there is a centralized translation component where we can change any UI or business logic if needed. If for example theTranslation
component was a legacy class component we could have updated it to use hooks (like above) and we would only have to do it in a single place before propagating to the entire project. Similarly if we want to add any UI logic to the translation (e.g. allow user to hover over the text to see the text in other languages) we only need to change a single file. - It’s now a lot easier to test files that use translation. Before refactoring we would have to mock the
useTranslation
somehow because we don’t want to test its implementation. With the refactored code we only need to check that we render the Translation component and that we pass the correct props.
Tip: We should follow the same tactic when using third party libraries. We should wrap the library into a (nested) component. This way we have a central point to change any logic or just replace the third party library all-together.
2. Components should have flexible render methods
Components should allow flexibility to render in different shapes. This increases re-usability of components.
For example in every application there are components which perform an action when clicked. A button that creates a new task, a dropdown item that deletes a comment or a pencil icon that makes a piece of text editable. These components essentially call a method on click but we could potentially render them in different parts of our app with different shapes (Button in one place and dropdown item in another).
Therefore every app should have an action component that could look like this:
This component essentially gets an onClick
prop that will be called when any of the rendered sub-component is clicked. The component will take only one (can be achieved in typescript usingRequireExactlyOne
from type-fest) of the remaining props. If it receives the iconProps
it will render an icon, if it receives buttonProps
it will render a button and so on. It also allows a renderProp
option where the parent component can take control and render whatever is needed.
Again, because this component is going to be a deeply nested component we can easily add more functionality to it, which will become available to our entire app. We could for example add functionality to open a modal and ask the user to confirm the action. We would just have to trigger the modal to open when the Icon
, Button
or Dropdown.Item
is clicked and then call the onClick
prop when the modal confirmation button is clicked.
Note: The power of this component (especially the renderProp
will be better understood in the following section)
3. Generalize your components for all the entities in the system
In most apps we perform similar actions for different entities in the system. For example we can delete a comment
, a post
, a group
e.t.c. Or we can set as favourite a task
, a post
or a user
. We are essentially performing the same action, the only thing that changes is the underlying entity. Therefore, it makes sense to create one reusable component to tackle this.
Generic Action component
A component to delete an entity could take the following form:
The two essential props that this component will take are the entityType
and the entityId
. This entityType
could be a comment
, a post
and so on depending on what entity we want to act on and the entityId
will be the id of the entity. With one component we can now delete any entity in our app.
Furthermore because we used the Action
component from the previous section we can render this action as an Icon
, Button
e.t.c.
So if we wanted to render a red, big button to delete a comment we would write something like this:
Or we could use the renderProp
to fully take control of what will be rendered by this component like this:
The same pattern could be extended for the other REST actions (Read, Create, Update or for GraphQL queries / mutations).
Generic Redux
For people using Redux
combined with react-redux
connect
function we can write a higher order component (HOC) to generalize how we connect to redux for actions to read any entity in the system.
The HOC above takes three props: entityType
, entityId
and children
. entityType
and entityId
are used in the mapStateToProps
and mapDispatchToProps
to return the entity from the state and to return a function that dispatches an action to read the entity. children
is expected to be a function that will receive the entity
and the read
function.
And here’s how we would use the above to read a user:
If you want to find out how to structure Redux following the pattern above you can find a more detailed guide here: https://medium.com/hackernoon/state-management-with-redux-50f3ec10c10a
Other use cases
Another area where we could use this pattern is for forms. Forms are very similar across an app. The things that usually change are the input types (e.g. textarea, input, select) and the field names (depending on the entity). So it would make sense to have a single form component which takes a fields
prop and an entityType
prop in a similar fashion to the above example.
4. Keep state business logic separate from UI in a component
You should always strive to separate the state management logic from the ui logic. Having components that do both makes them less reusable, more difficult to test and more difficult to refactor when you want to change the way you do state management.
As we saw with the previous example, theuseDeleteEntity
hook holds the logic to delete the entity, the Action
component holds the ui logic and then EntityDelete
brings the two together by implementing the delete logic and rendering the ui.
Here’s another example to emphasize this point:
Rather than writing the logic for setting the state inside a component we could extract it into a hook of its own. In the following example the toggling functionality is managed in the onClick
function of the button.
We could refactor the above to extract the toggling functionality into a useToggle
hook:
5. Embrace wishful thinking
A very helpful technique is to program by wishful thinking. What that means is that we can imagine that we have any component we wish in our app (even though it doesn’t exist yet) and start writing code as if they existed.
Say we want to load a list with the users of a group. What we want to do is to:
a) send an api request to fetch the users when the component mounts
b) show a placeholder until we get a response back from the server (to indicate to the user that something is being loaded)
c) once we get the response back show the list of the users.
Given these requirements we could write something like this:
What we are “wishing” here is to have a component called Fetch
, which will be responsible to do an api call and get users with some query parameters. While the api call is pending we want to show a placeholder and for this we are passing a component called Placeholder
, which can specify how it appears (in this case as a list). Once the api call is finished a component will be rendered specified by the children (as a function) passed. To do that the Fetch
component will call the children function with the users
fetched and as a result the UserList
component will be rendered.
In a few lines of code we loaded and displayed a list of users. And even though some of these components might not exist in the app, it’s now very easy to understand what needs to be developed. Even more, once the app has matured most of these components we “wished” were there, will actually be there, increasing the speed of development massively.
We could keep building on top of the above logic, adding even more props to the Fetch
component in order to specify pagination, sorting, filters e.t.c
6. Object oriented React
React has been all about functional programming. We can see with the introduction of hooks there is a strong desire to shift away from components written as classes and move entirely into a functional world. This is great for a few reasons but mainly because functional programming is very easy to reason about and testing becomes easy as well.
We could always though try different techniques and see if it makes sense in our apps. One such technique we found that works well in our app is to create classes for the data . We cast the json data we get from the api for each entity into its own class.
What we end up with is something like this:
Instead of using the json data we get from the api directly we could now create a class like so: new Group(groupJsonDataFromApi)
. Because this is now a class it unlocks some extra functionality.
In the file above we have one class for our Post
entity and one class for our Group
entity. They both share some fields like id and permissions (which we can extract into an Entity
class). We can add getters for each class like the canDelete
getter. This can compute if the authenticated user can delete a certain entity. Note that the function to compute if a user has permissions to perform the delete action might differ for each entity. For example in our app you can delete a comment if you have edit permissions on the entity but you need owner permissions to delete a group.
We could therefore use this to refactor the EntityActionDelete
component we introduced in section 3 to:
We now only need to pass an entity
prop, which is an instance of a Group or a Post class (fetched from the api). This will give us direct access to the entityType
and id
.
Also, because of the canDelete
getter, we can now protect against users without the right permission level and return null in the render method. So we don’t have to worry about accidentally rendering something that shouldn’t be seen by the user.
This whole technique comes with great Typescript
support. We can limit the types of entities we can pass to the component (e.g. only Group
or Post
) to only entities that are deletable in the app. Also we get the assurance that both these entities have declared the canDelete
getter otherwise typescript would give us a warning.
In a large React app where re-usability is high and components tend to be generalized this technique proves very helpful because you can customize the behaviour of each entity in its respective class, without changing anything in the component. It is very scalable, there is good separation of concerns, easily testable and we get great typescript support.
7+. More best practices
You can find the second part of this article in the link below:
https://onoufriosm.medium.com/react-best-practices-react-at-scale-part-2-11b69b96f5e