Saturday, April 20, 2024
HomeTechnologySoftwareModularizing React Applications with Established UI Patterns

Modularizing React Applications with Established UI Patterns

Date:

Related stories

spot_imgspot_img


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

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.

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.

Martin Fowler

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 Extract Function
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 a onSelect 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.

We’re releasing this article in installments. The next installments
will add more features to the example, showing how they take advantage
of the improved structure and prompt its further evolution.

To find out when we publish the next installment subscribe to the
site’s
RSS feed,
Juntao’s twitter stream,
Martin’s
twitter stream, or Martin’s
Mastodon feed.






Source link

Latest stories

spot_img