Build maintainable React apps — Part II
Ease change: future-proof component code
This article is Part II of the “Build maintainable React apps — Series.”
Part II: Ease change, future-proof component code
- State management
- Loosely Coupled Component
- Components decomposition patterns
- Enable React warning and keep your dependencies up-to-date
React applications are always evolving with refactoring from:
- new features
- existing features evolution
- bug fixing
- dependencies updates
With that in mind, and far from wanting to predict the future,
we will see how to help your application embrace future changes by having some tips and best practice on:
- State management
- Loosely Coupled Component
- Components decomposition patterns
- Enable React warning and keep your dependencies up-to-date
—
1. State management
State management is often, at scale, the root of many performances and consistency issues in React apps.
This subject has already a lot of great articles from experts in the fields, let me share some:
If you start building an app
I would recommend looking at this tweet that sums the approach to maintainable state management:
For more information, the original article:
If you already have an existing app
Identifying your application parts will help you refining where the wrong
“state lift-up” has been done and refactoring it.
More generally, the following rule of thumb can be followed:
- keep state as local as possible by using
useState()
- use
React.Context
to avoid prop-drilling - use
redux/mobx
only for the application-level state (configuration, session)
—
2. Loosely Coupled Component
Since each line of code is meant to be changed, let’s try to make it easy to remove, update, or move.
Coupling is definitely the enemy of change, but what is “coupling”?
[Coupling is] the degree to which software components depend on each other.
In this section, we will take a look at examples of how data fetching and manipulation is a great source of coupling:
- i. The loss of the Container/View pattern?
- ii. Coupling at the data manipulation-level
We will see examples based on the following UI:
—
i. The loss of the Container/View pattern?
The rise of hooks marked the end of the HoC and Container/View patterns; however, does it mean that components should carry all the data fetching knowledge?
Let’s see an example of a <Chat />
component fetching data using Apollo GraphQL useQuery()
hook:
Since our <Chat />
component is directly using useQuery()
, it makes sense to also define the GraphQL query in the component file, also the related TypeScript data types, and include the conversations sorting logic in the component.
However, a component is meant to handle UI aspects like:
- Handle the display logic accordingly to the product design system definition
Handle loading UI states, UI business logic - Handle UI interactions
ex: dynamic styling, derived state, error UI states
All the above component’s responsibilities already represent a lot of logic and complexity.
When it comes to data, a good modern solution to replace HoC or Container/View is custom hooks.
Our refactored version of <Chat />
component is relying on a useGetConversation()
(getConversations.ts) custom hook that provides typed and sorted data.
With this approach, our <Chat />
component is:
- no longer aware of the underlying data fetching implementation
- no longer responsible for data related logic (sorting, etc.)
- has free space to welcome UI related complexity (state, logic, etc.)
—
Bonus:
If you already use React with GraphQL in your application, I highly suggest you take a look at GraphQL Code Generator that will generate those custom hooks for you: https://graphql-code-generator.com/
No need to maintain your GraphQL queries and associated types and hooks; you can focus 100% on the components.
—
ii. Coupling at the data manipulation-level
We saw how to make sure that our component code & complexity is concentrated on the UI aspects.
However, some of those UI aspects rely on data, which can introduce some unnecessary coupling.
Our <Conversation />
has some un-necessary data-level coupling:
- lines 13–27: derived data from props is polluting component’s body
- lines 41, 47: business rules of defining what is a “conversation type” is inlined in the rendering logic, making it very fragile to changes
Let’s take a look at the refactored version of <Conversation />
:
<Conversation />
does not know what makes a conversation “audio” or “media”, breaking changes won’t directly impact the component which now relies on data helpers<Conversation />
body is cleaner with derived data from props being computed by a helperuserTimes()
exposed by getConversations.ts
By being coupled to only to data that is necessary for UI aspects, this component is now better equipped to face future changes.
—
In conclusion, keeping your components “future-proof” can be achieved by:
- Using custom hooks is a good alternative to the Container/View pattern.
Let your component focus on the UI aspect. - By ensuring your component relies directly only on data that is displayed, otherwise, use a business logic helper or custom hooks.
—
3. Component decomposition patterns
In any company, being able to change code quickly is essential.
Applied to code, this could be resumed to:
A good line of code is easy to remove
In React app, “easy to remove” can be translated to:
- Prefer duplication over wrong abstraction
- Well defined/split components
—
i. Prefer duplication over wrong abstraction
For us, Software Engineer, the temptation to have “perfect”, beautiful and factorized code is omnipresent.
However, abstractions, especially wrong ones, can come at a high cost of change.
Clues of “bad abstractions.”
Here are some pointers that might help you in identifying wrong abstractions:
- The abstraction component (ex:
<EditableContent record={record} />
) contains some specific conditions:
if (record.__typename === "...") { /* render this */ }
else { /* render that */ }
- Your abstraction exposes many props in order to be configurable
Combo: configuration props are inter-dependent (compounded) - Your abstraction is based on class inheritance
Good abstractions use-cases
Of course, abstraction is useful when scaling a React app, especially when the product/company is reaching a more stable state.
Abstraction is great to avoid duplication of UI elements code:
- Your abstraction embed common UI pattern and provides props to configure the business logic outside of it
(ex: <AddToList listName={} onSelect={} />)
- UI-kit is a good example of good abstraction
Helping scaling CSS code and also bringing consistency in the UI by avoiding issue brought by code duplication
Conclusion
Of course, the point is not to say that abstraction is prohibited.
A good rule of thumb when it comes to abstraction is to avoid to apply it to business logic by:
- Defining “abstract component” (like
<Button />
,<Avatar />
) for UI patterns - Relying on custom hooks for custom logic and define shared/common ones if the complexity remains low (not specificity conditions, etc.)
—
ii. Well defined/split components
In this section, we won’t look at general rules regarding components design, but we will be more specific and try to answer the following question:
What is a good future-proof component design?
A good part of “future-proofing” components comes from the way you breakdown a given UI into a set of components; this is called decomposition.
There are some well-known decomposition patterns, such as:
- List/Item: Given a UI listing element, you will have at least two components, a list component, and item component
- Button/Action: Encapsulate action logic in a dedicated component
- Pop-over: Add “target” component triggers the display of another (hover, click)
Those patterns are omnipresent and are automatisms when it comes to building a UI.
However, real-world applications are not based on 1–1 binding between design and components, for the main reason: state.
A good “decomposition rule of thumb” is:
A component is a the meeting point of the simplest state and UI purpose
The “UI purpose” of a component is defined by design provided by your Product Designer.
Notice that the “simplest” state is used instead of a “smaller” state.
Our goal being to build “future proof” components, the idea is to find the purest state for our components in order to facilitate refactoring and testing.
Practical example
Let’s see an example with a new UI of a blog product to implement in our React application (top-menu excluded).
Following our “Decomposition patterns,” we could define the first draft with the following components:
<ArticlesTab />
:
top component (not outlined)<OnboardingNotice />
<ArticleTypeFilters />
<ArticleSearch />
<ArticlesList />
Articles container (not outlined)<Article />
Article item<ArticlesListPagination />
The filtering here introduces a good example of state decomposition:
<ArticlesTab />
will handle the state of the currentsearch
and current selectedarticleType
- We will need a new component (
<Articles />
), not linked to the UI, that will handle the state (pagination, querying data) for a givensearch
andarticleType
(<ArticlesListPagination>
could also be moved inside<ArticlesList>
) - Finally,
<OnboardingNotice />
will handle its state independently using persistent storage (ex:localStorage
) to determine whenever or not it should display a notice
This given meeting point of the simplest state and UI purpose results in the following component structure:
Conclusion
Decomposition patterns and the UI provided by your Product Designer are a good starting point to build well-defined components.
Then, confronting this UI-decomposition with the needed state and data will — most of the time — naturally shape the proper component’s structure as long as you keep simplicity in mind (not the smallest).
NB: How do we know if a component state is “simple enough”?
A good way to figure out is to — try to — test it! (see the Part III of this Series)
—
4. Enable React warning and keep your dependencies up-to-date
Here are some tips in order to truly embrace change in your React application.
React Strict Mode
React Strict Mode, when set up in your application, will allow you to foresee breaking changes in the upcoming React version that will impact your application.
The following warnings are offered by Strict Mode:
- Identifying components with unsafe lifecycles
- Warning about legacy string ref API usage
- Warning about deprecated findDOMNode usage
- Detecting unexpected side effects
- Detecting legacy context API
This is especially helpful to ensure that your team will be able to migrate to a newer version of React.
—
Update your dependencies, as often as possible
Even without Strict Mode, React will emit warnings for deprecated API usage; this is a common practice in open source libraries.
For this reason, updating your application dependencies to latest patch or minor version as often as possible is an excellent practice to foresee which part of your application needs more attention.
A well-known tool — now free for Github users — is Dependabot.
You may think that updating often your dependencies is a frequent loss of time; however, a study proved the contrary.
streamlining refactors is more efficient that rebuilding from scratch or doing nothing
We all know projects where a “code freeze” of multiple weeks was necessary to update dependencies, or even worse, updating was no longer possible.
Conclusion
- Custom hooks are an excellent alternative to the Container/View pattern.
Let your component focus on UI aspects. - Ensure your component relies directly only on data that is displayed; otherwise, use a business logic helper, custom hooks.
- Beware of bad abstractions; they are the most likely to prevent change.
- Decomposition patterns and the UI provided by your Product Designer are a good starting point to build well-defined components
The journey continues
Learn more tips and principles on maintainable React applications by reading the Part III “Testing strategy and testable components” of the Build maintainable React apps — Series.