Established UI patterns are often underutilized in the frontend development world, despite their proven effectiveness in solving complex problems in UI design. This article explores the application of established UI building patterns to the React world, with a refactoring journey code example to showcase the benefits. The emphasis is placed on how layering architecture can help organize the React application for improved responsiveness and future changes.
Welcome to the real world of React applications.
React is a humble library for building views
It's easy to forget that React, at its core, is a library (not a framework) that helps you build the user interface.
A JavaScript library for building user interfaces -- React Homepage
It may sound pretty straightforward. But I have seen many cases where people write the data fetching, reshaping logic right in the place where it's consumed. For example, fetching data inside a React component, in the useEffect block right above the rendering, or performing data mapping/transforming once they got the response from the server side.
useEffect(() => {
fetch('https://address.service/api')
.then((res) => res.json())
.then((data) => {
const addresses = data.map((item) => ({
street: item.streetName,
address: item.streetAddress,
postcode: item.postCode,
}));
setAddresses(addresses);
});
}, []);
// the actual rendering...
Perhaps because there is yet to be a universal standard in the frontend world, or it's just a bad programming habit. Frontend applications should not be treated too differently from regular software applications. In the frontend world, you still use separation of concerns in general to arrange the code structure. And all the proven useful design patterns still apply.
Welcome to the real world React application
Most developers were impressed by React's simplicity and the idea that a user interface can be expressed as a pure function to map data into the DOM. And to a certain extent, it IS.
But developers start to struggle when they need to send a network request to a backend or perform page navigation, as these side effects make the component less "pure". And once you consider these different states (either global state or local state), things quickly get complicated, and the dark side of the user interface emerges.
Apart from the user interface
React itself doesn’t care much about where to put calculation or business logic, which is fair as it’s only a library for building user interfaces. And beyond that view layer, a frontend application has other parts as well. To make the application work, you will need a router, local storage, cache at different levels, network requests, 3rd-party integrations, 3rd-party login, security, logging, performance tuning, etc.
With all this extra context, trying to squeeze everything into React components or hooks is generally not a good idea. The reason is mixing concepts in one place generally leads to more confusion. At first, the component sets up some network request for order status, and then there is some logic to trim off leading space from a string and then navigate somewhere else. The reader must constantly reset their logic flow and jump back and forth from different levels of details.
On the whole I've found this to be an effective form of modularization for many applications and one that I regularly use and encourage. It's biggest advantage is that it allows me to increase my focus by allowing me to think about the three topics (i.e., view, model, data) relatively independently. -- Martin Fowler
Layered architectures have been used to cope with the challenges in large GUI applications, and certainly, we can use these established patterns of front-end organization in our "React applications".
The evolution of a React application
For small or one-off projects, you might find that all logic is just written inside React components. You may see one or only a few components in total. The code looks pretty much like HTML, with only some variable or state used to make the page "dynamic".
As the application grows, and more and more code is added to the codebase, without a proper way to organize them, soon the codebase will turn into an unmaintainable state.
Single Component Application
It starts innocently enough. You call it a Single Component Application. But soon, you realize one single component requires a lot of time just to read what is happening.
Multiple Component Application
You decided to split the component into several components, with these structures reflecting what’s happening on the result HTML. This is a good idea, and it helps you to focus on one component at a time.
But as your application grows, apart from the view, there are things like sending network requests, converting data into different shapes for the view to consume, and collecting data to send back to the server. Having this code inside components doesn’t feel right as they’re not really about user interfaces.
State management with hooks
It’s a better idea to split this logic into separate places. Luckily in React, you can define your own hooks. This is a great way to share these states and the logic of whenever states change.
That’s awesome! You have a bunch of elements extracted from your single component application, and you have a few pure presentational components and some reusable hooks that make other components stateful. The only problem is that in hooks, apart from the side effect and state management, some logic doesn’t seem to belong to the state management but purely calculations.
Business models emerged
So you’ve started to become aware that extracting this logic into yet another place can bring you many benefits. For example, with that split, the logic can be cohesive and independent of any views. Then you extract a few domain objects.
These simple objects can handle data mapping (from one format to another), check nulls and use fallback values as required. Also, as the amount of these domain objects grows, you find you need some inheritance or polymorphism to make things even cleaner.
Layered frontend application
The application keeps evolving, and then you find some patterns emerge. There are a bunch of objects that do not belong to any user interface, and they also don't care about whether the underlying data is from remote service, local storage, or cache. And then, you want to split them into different layers.
Introduction of the Payment feature
I’m using an oversimplified online ordering application as a starting point. In this application, a customer can pick up some products and add them to the order, and then they will need to select one of the payment methods to continue.
Let’s say that after reading the React hello world doc and a couple of stackoverflow searches, you came up with some code like this:
export const Payment = ({ amount }: { amount: number }) => {
const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
[]
);
useEffect(() => {
const fetchPaymentMethods = async () => {
const url = 'https://online-ordering.com/api/payment-methods';
const response = await fetch(url);
const methods: RemotePaymentMethod[] = await response.json();
if (methods.length > 0) {
const extended: LocalPaymentMethod[] = methods.map((method) => ({
provider: method.name,
label: `Pay with ${method.name}`,
}));
extended.push({ provider: 'cash', label: 'Pay in cash' });
setPaymentMethods(extended);
} else {
setPaymentMethods([]);
}
};
fetchPaymentMethods();
}, []);
return (
<div>
<h3>Payment</h3>
<div>
{paymentMethods.map((method) => (
<label key={method.provider}>
<input
type='radio'
name='payment'
value={method.provider}
defaultChecked={method.provider === 'cash'}
/>
<span>{method.label}</span>
</label>
))}
</div>
<button>${amount}</button>
</div>
);
};
The code above is pretty typical. However, as we mentioned above, the code has mixed different concerns all in a single component and makes it a bit difficult to read.
The problem with the initial implementation
The first issue I would like to address is how busy the component is. By that, I mean Payment deals with different things and makes the code difficult to read as you have to switch context in your head as you read.
In order to make any changes you have to comprehend:
- How to initialize network request 🌐
- How to map the data to a local format that the component can understand 🗺️
- How to render each payment method 🎨
- And the rendering logic for Payment component itself 🖼️
It’s good practice to split view and non-view code into separate places. The reason is, in general, views are changing more frequently than non-view logic.
The split of view and non-view code
In React, we can use a custom hook to maintain the state of a component while keeping the component itself more or less stateless. We can use Extract Function to create a function called usePaymentMethods.
const usePaymentMethods = () => {
const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
[]
);
useEffect(() => {
// ... fetching login ...
}, []);
return {
paymentMethods,
};
};
This returns a paymentMethods array as internal state and is ready to be used in rendering. So the logic in Payment can be simplified.
Split the view by extracting sub component
Also, if we can make a component a pure function - meaning given any input, the output is certain - that would help us a lot in writing tests, understanding the code and even reusing the component elsewhere.
We can use Extract Component to create PaymentMethods.
const PaymentMethods = ({
paymentMethods,
}: {
paymentMethods: LocalPaymentMethod[];
}) => (
<>
{paymentMethods.map((method) => (
<label key={method.provider}>
{/* ... */}
<span>{method.label}</span>
</label>
))}
</>
);
Data modelling to encapsulate logic
So far, the changes we have made are all about splitting view and non-view code. However, if you look closely, there is still room for improvement.
We could have a class PaymentMethod with the data and behaviour centralised into a single place:
class PaymentMethod {
private remotePaymentMethod: RemotePaymentMethod;
constructor(remotePaymentMethod: RemotePaymentMethod) {
this.remotePaymentMethod = remotePaymentMethod;
}
get provider() {
return this.remotePaymentMethod.name;
}
get label() {
if (this.provider === 'cash') {
return `Pay in ${this.provider}`;
}
return `Pay with ${this.provider}`;
}
get isDefaultMethod() {
return this.provider === 'cash';
}
}
With the class, I can define the default cash payment method const payInCash = new PaymentMethod({ name: "cash" }); and use it during conversion.
New requirement: donate to a charity
Let’s examine the theory here with some further changes to the application. The new requirement is that we want to offer an option for customers to donate a small amount of money as a tip to a charity along with their order.
Internal state: agree to donation
To make these changes in Payment, we need a boolean state agreeToDonate.
const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
const { total, tip } = useMemo(
() => ({
total: agreeToDonate ? Math.floor(amount + 1) : amount,
tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
}),
[amount, agreeToDonate]
);
Extract a hook to the rescue
It sounds like we need an object that:
- Takes the original amount as input
- Returns
totalandtipwheneveragreeToDonatechanged.
It sounds like a perfect place for a custom hook again, right?
export const useRoundUp = (amount: number) => {
const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
const { total, tip } = useMemo();
// ... logic ...
const updateAgreeToDonate = () => {
setAgreeToDonate((agreeToDonate) => !agreeToDonate);
};
return {
total,
tip,
agreeToDonate,
updateAgreeToDonate,
};
};
The shotgun surgery problem
The round-up looks good so far, but as the business expands to other countries, it comes with new requirements. For example, the Japan market needs to round up to the nearest hundred.
If we simply add if-else checks everywhere for countryCode, we encounter the famous “shotgun surgery” smell. This essentially says that we'll have to touch several modules whenever we need to modify the code.
Polymorphism to the rescue
We can use polymorphism to replace these switch cases. The first thing we can do is examine all the variations to see what need to be extracted into a class.
export interface PaymentStrategy {
getRoundUpAmount(amount: number): number;
getTip(amount: number): number;
}
A concrete implementation of the strategy interface would be like PaymentStrategyAU.
export class PaymentStrategyAU implements PaymentStrategy {
get currencySign(): string {
return '$';
}
getRoundUpAmount(amount: number): number {
return Math.floor(amount + 1);
}
// ...
}
Now, instead of depending on scattered logic, our components only rely on a single class PaymentStrategy.
Push the design a bit further: extract a network client
If I keep this "Separation of Concerns" mindset, the next step is to do something to relieve the mixing in usePaymentMethods hook.
const fetchPaymentMethods = async () => {
const response = await fetch('https://api/payment-methods?countryCode=AU');
const methods: RemotePaymentMethod[] = await response.json();
return convertPaymentMethods(methods);
};
This small class does two things, fetch and convert. It acts like an Anti-Corruption Layer.
The benefits of having these layers
As demonstrated above, these layers brings us many advantages:
- Enhanced maintainability: Easier to locate and fix defects.
- Increased modularity: Easier to reuse code and build new features.
- Enhanced readability: Easier to understand and follow the logic.
- Improved scalability: Easier to add new features without affecting the entire system.
- Migrate to other techstack: We can replace the view layer without changing the underlying models and logic.
Conclusion
Building React application, or a frontend application with React as its view, should not be treated as a new type of software. Most of the patterns and principles for building the traditional user interface still apply.
The benefit of having these layers in frontend applications is that you only need to understand one piece without worrying about others. Also, with the improvement of reusability, making changes to existing code would be relatively more manageable than before.