While I’ve put React application, there isn’t such a thing as React application. I mean, there are
front-end applications written in JavaScript or TypeScript that happen to
use React as their views. However, I think it’s not fair to call them React
applications, just as we wouldn’t call a Java EE application JSP
application.
More often than not, people squeeze different things into React
components or hooks to make the application work. This type of
less-organised structure isn’t a problem if the application is small or
mostly without much business logic. However, as more business logic shifted
to front-end in many cases, this everything-in-component shows problems. To
be more specific, the effort of understanding such type of code is
relatively high, as well as the increased risk to code modification.
In this article, I would like to discuss a few patterns and techniques
you can use to reshape your “React application” into a regular one, and only
with React as its view (you can even swap these views into another view
library without too much efforts).
The critical point here is you should analyse what role each part of the
code is playing within an application (even on the surface, they might be
packed in the same file). Separate view from no-view logic, split the
no-view logic further by their responsibilities and place them in the
right places.
The benefit of this separation is that it allows you to make changes in
the underlying domain logic without worrying too much about the surface
views, or vice versa. Also, it can increase the reusability of the domain
logic in other places as they are not coupled to any other parts.
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.
In this context, it is emphasized that React is a JavaScript library
that concentrates on a particular aspect of web development, namely UI
components, and offers ample freedom in terms of the design of the
application and its overall structure.
A JavaScript library for building user interfaces
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.
Packing all the code into components may work in small applications
like a Todo or one-form application. Still, the efforts to understand
such application will be significant once it reaches a certain level.
Not to mention adding new features or fixing existing defects.
If we could separate different concerns into files or folders with
structures, the mental load required to understand the application would
be significantly reduced. And you only have to focus on one thing at a
time. Luckily, there are already some well-proven patterns back to the
pre-web time. These design principles and patterns are explored and
discussed well to solve the common user interface problems – but in the
desktop GUI application context.
Martin Fowler has a great summary of the concept of view-model-data
layering.
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.
Layered architectures have been used to cope 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”. Some might send requests to fetch
data on useEffect
after the components render.
As the application grows, and more and more code are added to codebase.
Without a proper way to organise them, soon the codebase will turn into
unmaintainable state, meaning that even adding small features can be
time-consuming as developers need more time to read the code.
So I’ll list a few steps that can help to relief the maintainable
problem. It generally require a bit more efforts, but it will pay off to
have the structure in you application. Let’s have a quick review of these
steps to build front-end applications that scale.
Single Component Application
It can be called pretty much a Single Component Application:
Figure 1: Single Component Application
But soon, you realise one single component requires a lot of time
just to read what is going on. For example, there is logic to iterate
through a list and generate each item. Also, there is some logic for
using 3rd-party components with only a few configuration code, apart
from other logic.
Multiple Component Application
You decided to split the component into several components, with
these structures reflecting what’s happening on the result HTML is a
good idea, and it helps you to focus on one component at a time.
Figure 2: Multiple Component Application
And 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. And
having this code inside components doesn’t feel right as they’re not
really about user interfaces. Also, some components have too many
internal states.
State management with hooks
It’s a better idea to split this logic into a separate places.
Luckily in React, you can define your own hooks. This is a great way to
share these state and the logic of whenever states change.
Figure 3: State management with hooks
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 pure 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. Thus you applied many
design patterns you found helpful from other places into the front-end
application here.
Figure 4: Business models
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. Here is a detailed explanation about the layer
splitting Presentation Domain Data Layering.
Figure 5: Layered frontend application
The above evolution process is a high-level overview, and you should
have a taste of how you should structure your code or at least what the
direction should be. However, there will be many details you need to
consider before applying the theory in your application.
In the following sections, I’ll walk you through a feature I
extracted from a real project to demonstrate all the patterns and design
principles I think useful for big frontend applications.
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.
Figure 6: Payment section
These payment method options are configured on the server side, and
customers from different countries may see other options. For example,
Apple Pay may only be popular in some countries. The radio buttons are
data-driven – whatever is fetched from the backend service will be
surfaced. The only exception is that when no configured payment methods
are returned, we don’t show anything and treat it as “pay in cash” by
default.
For simplicity, I’ll skip the actual payment process and focus on the
Payment
component. 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:
src/Payment.tsx…
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. You might have seen it in the get
started tutorial somewhere. And it’s not necessary bad. 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 initialise 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
.
src/Payment.tsx…
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> ); };
It’s not a big problem at this stage for this simple example.
However, as the code gets bigger and more complex, we’ll need to
refactoring them a bit.
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. Also, as they deal with different aspects of the
application, separating them allows you to focus on a particular
self-contained module that is much more manageable when implementing new
features.
The split of view and non-view code
In React, we can use a custom hook to maintain state of a component
while keeping the component itself more or less stateless. We can
use
to create a function called usePaymentMethods
(the
prefix use
is a convention in React to indicate the function is a hook
and handling some states in it):
src/Payment.tsx…
const usePaymentMethods = () => {
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 {
paymentMethods,
};
};
This returns a paymentMethods
array (in type LocalPaymentMethod
) as
internal state and is ready to be used in rendering. So the logic in
Payment
can be simplified as:
src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => {
const { paymentMethods } = usePaymentMethods();
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>
);
};
This helps relieve the pain in the Payment
component. However, if you
look at the block for iterating through paymentMethods
, it seems a
concept is missing here. In other words, this block deserves its own
component. Ideally, we want each component to focus on, only one
thing.
Data modelling to encapsulate logic
So far, the changes we have made are all about splitting view and
non-view code into different places. It works well. The hook handles data
fetching and reshaping. Both Payment
and PaymentMethods
are relatively
small and easy to understand.
However, if you look closely, there is still room for improvement. To
start with, in the pure function component PaymentMethods
, we have a bit
of logic to check if a payment method should be checked by default:
src/Payment.tsx…
const PaymentMethods = ({
paymentMethods,
}: {
paymentMethods: LocalPaymentMethod[];
}) => (
<>
{paymentMethods.map((method) => (
<label key={method.provider}>
<input
type="radio"
name="payment"
value={method.provider}
defaultChecked={method.provider === "cash"}
/>
<span>{method.label}</span>
</label>
))}
</>
);
These test statements in a view can be considered a logic leak, and
gradually they can be scatted in different places and make modification
harder.
Another point of potential logic leakage is in the data conversion
where we fetch data:
src/Payment.tsx…
const usePaymentMethods = () => { 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 { paymentMethods, }; };
Note the anonymous function inside methods.map
does the conversion
silently, and this logic, along with the method.provider === "cash"
above can be extracted into a class.
We could have a class PaymentMethod
with the data and behaviour
centralised into a single place:
src/PaymentMethod.ts…
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 during the conversion – after the payment methods are fetched from
the remote service – I can construct the PaymentMethod
object in-place. Or even
extract a small function called convertPaymentMethods
:
src/usePaymentMethods.ts…
const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
if (methods.length === 0) {
return [];
}
const extended: PaymentMethod[] = methods.map(
(method) => new PaymentMethod(method)
);
extended.push(payInCash);
return extended;
};
Also, in the PaymentMethods
component, we don’t use the
method.provider === "cash"
to check anymore, and instead call the
getter
:
src/PaymentMethods.tsx…
export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
<>
{options.map((method) => (
<label key={method.provider}>
<input
type="radio"
name="payment"
value={method.provider}
defaultChecked={method.isDefaultMethod}
/>
<span>{method.label}</span>
</label>
))}
</>
);
Now we’re restructuring our Payment
component into a bunch of smaller
parts that work together to finish the work.
Figure 7: Refactored Payment with more parts that can be composed easily
The benefits of the new structure
- Having a class encapsulates all the logic around a payment method. It’s a
domain object and doesn’t have any UI-related information. So testing and
potentially modifying logic here is much easier than when embedded in a
view. - The new extracted component
PaymentMethods
is a pure function and only
depends on a domain object array, which makes it super easy to test and reuse
elsewhere. We might need to pass in aonSelect
callback to it, but even in
that case, it’s a pure function and doesn’t have to touch any external
states. - Each part of the feature is clear. If a new requirement comes, we can
navigate to the right place without reading all the code.
I have to make the example in this article sufficiently complex so that
many patterns can be extracted. All these patterns and principles are
there to help simplify our code’s modifications.