Software Development

Modularizing React Applications with Established UI Patterns


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.

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.

For example, if the order amount is $19.80, we ask if they would like
to donate $0.20. And if a user agrees to donate it, we’ll show the total
number on the button.

Figure 8: Donate to a charity

Before we make any changes, let’s have a quick look at the current code
structure. I prefer have different parts in their folder so it’s easy for
me to navigate when it grows bigger.

      src
      ├── App.tsx
      ├── components
      │   ├── Payment.tsx
      │   └── PaymentMethods.tsx
      ├── hooks
      │   └── usePaymentMethods.ts
      ├── models
      │   └── PaymentMethod.ts
      └── types.ts
      

App.tsx is the main entry, it uses Payment component, and Payment
uses PaymentMethods for rendering different payment options. The hook
usePaymentMethods is responsible for fetching data from remote service
and then convert it to a PaymentMethod domain object that is used to
hold label and the isDefaultChecked flag.

Internal state: agree to donation

To make these changes in Payment, we need a boolean state
agreeToDonate to indicate whether a user selected the checkbox on the
page.

src/Payment.tsx…

  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]
  );

The function Math.floor will round the number down so we can get the
correct amount when the user selects agreeToDonate, and the difference
between the rounded-up value and the original amount will be assigned to tip.

And for the view, the JSX will be a checkbox plus a short
description:

src/Payment.tsx…

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <div>
        <label>
          <input
            type="checkbox"
            onChange={handleChange}
            checked={agreeToDonate}
          />
          <p>
            {agreeToDonate
              ? "Thanks for your donation."
              : `I would like to donate $${tip} to charity.`}
          </p>
        </label>
      </div>
      <button>${total}</button>
    </div>
  );

With these new changes, our code starts handling multiple things again.
It’s essential to stay alert for potential mixing of view and non-view
code. If you find any unnecessary mixing, look for ways to split them.

Note that it’s not a set-in-stone rule. Keep things all together nice
and tidy for small and cohesive components, so you don’t have to look in
multiple places to understand the overall behaviour. Generally, you should
be aware to avoid the component file growing too big to comprehend.

More changes about round-up logic

The round-up looks good so far, and as the business expands to other
countries, it comes with new requirements. The same logic doesn’t work in
Japan market as 0.1 Yen is too small as a donation, and it needs to round
up to the nearest hundred for the Japanese currency. And for Denmark, it
needs to round up to the nearest tens.

It sounds like an easy fix. All I need is a countryCode passed into
the Payment component, right?

<Payment amount={3312} countryCode="JP" />;

And because all of the logic is now defined in the useRoundUp hook, I
can also pass the countryCode through to the hook.

const useRoundUp = (amount: number, countryCode: string) => {
  //...

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate
        ? countryCode === "JP"
          ? Math.floor(amount / 100 + 1) * 100
          : Math.floor(amount + 1)
        : amount,
      //...
    }),
    [amount, agreeToDonate, countryCode]
  );
  //...
};

You will notice that the if-else can go on and on as a new
countryCode is added in the useEffect block. And for the
getTipMessage, we need the same if-else checks as a different country
may use other currency sign (instead of a dollar sign by default):

const formatCheckboxLabel = (
  agreeToDonate: boolean,
  tip: number,
  countryCode: string
) => {
  const currencySign = countryCode === "JP" ? "¥" : "$";

  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate ${currencySign}${tip} to charity.`;
};

One last thing we also need to change is the currency sign on the
button:

<button>
  {countryCode === "JP" ? "¥" : "$"}
  {total}
</button>;

The shotgun surgery problem

This scenario is the famous “shotgun surgery” smell we see in
many places (not particularly in React applications). This essentially
says that we’ll have to touch several modules whenever we need to modify
the code for either a bug fixing or adding a new feature. And indeed, it’s
easier to make mistakes with this many changes, especially when your tests
are insufficient.

Figure 10: The shotgun surgery smell

As illustrated above, the coloured lines indicate branches of country
code checks that cross many files. In views, we’ll need to do separate
things for different country code, while in hooks, we’ll need similar
branches. And whenever we need to add a new country code, we’ll have to
touch all these parts.

For example, if we consider Denmark as a new country the business is
expanding to, we’ll end up with code in many places like:

const currencySignMap = {
  JP: "¥",
  DK: "Kr.",
  AU: "$",
};

const getCurrencySign = (countryCode: CountryCode) =>
  currencySignMap[countryCode];

One possible solution for the problem of having branches scattered in
different places is to use polymorphism to replace these switch cases or
table look-up logic. We can use Extract Class on those
properties and then Replace Conditional with Polymorphism.

Polymorphism to the rescue

The first thing we can do is examine all the variations to see what
need to be extracted into a class. For example, different countries have
different currency signs, so getCurrencySign can be extracted into a
public interface. Also ,countries might have different round-up
algorithms, thus getRoundUpAmount and getTip can go to the
interface.

export interface PaymentStrategy {
  getRoundUpAmount(amount: number): number;

  getTip(amount: number): number;
}

A concrete implementation of the strategy interface would be like
following the code snippet: PaymentStrategyAU.

export class PaymentStrategyAU implements PaymentStrategy {
  get currencySign(): string {
    return "$";
  }

  getRoundUpAmount(amount: number): number {
    return Math.floor(amount + 1);
  }

  getTip(amount: number): number {
    return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
  }
}

Note here the interface and classes have nothing to do with the UI
directly. This logic can be shared in other places in the application or
even moved to backend services (if the backend is written in Node, for
example).

We could have subclasses for each country, and each has the country specific
round-up logic. However, as function is first-class citizen in JavaScript, we
can pass in the round-up algorithm into the strategy implementation to make the
code less overhead without subclasses. And becaues we have only one
implementation of the interface, we can use Inline Class to
reduce the single-implementation-interface.

src/models/CountryPayment.ts…

  export class CountryPayment {
    private readonly _currencySign: string;
    private readonly algorithm: RoundUpStrategy;
  
    public constructor(currencySign: string, roundUpAlgorithm: RoundUpStrategy) {
      this._currencySign = currencySign;
      this.algorithm = roundUpAlgorithm;
    }
  
    get currencySign(): string {
      return this._currencySign;
    }
  
    getRoundUpAmount(amount: number): number {
      return this.algorithm(amount);
    }
  
    getTip(amount: number): number {
      return calculateTipFor(this.getRoundUpAmount.bind(this))(amount);
    }
  }

As illustrated below, instead of depend on scattered logic in
components and hooks, they now only rely on a single class
PaymentStrategy. And at runtime, we can easily substitute one instance
of PaymentStrategy for another (the red, green and blue square indicates
different instances of PaymentStrategy class).

Figure 11: Extract class to encapsulate logic

And the useRoundUp hook, the code could be simplified as:

src/hooks/useRoundUp.ts…

  export const useRoundUp = (amount: number, strategy: PaymentStrategy) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
  
    const { total, tip } = useMemo(
      () => ({
        total: agreeToDonate ? strategy.getRoundUpAmount(amount) : amount,
        tip: strategy.getTip(amount),
      }),
      [agreeToDonate, amount, strategy]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

In the Payment component, we pass the strategy from props through
to the hook:

src/components/Payment.tsx…

  export const Payment = ({
    amount,
    strategy = new PaymentStrategy("$", roundUpToNearestInteger),
  }: {
    amount: number;
    strategy?: PaymentStrategy;
  }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(
      amount,
      strategy
    );
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <DonationCheckbox
          onChange={updateAgreeToDonate}
          checked={agreeToDonate}
          content={formatCheckboxLabel(agreeToDonate, tip, strategy)}
        />
        <button>{formatButtonLabel(strategy, total)}</button>
      </div>
    );
  };

And I then did a bit clean up to extract a few helper functions for
generating the labels:

src/utils.ts…

  export const formatCheckboxLabel = (
    agreeToDonate: boolean,
    tip: number,
    strategy: CountryPayment
  ) => {
    return agreeToDonate
      ? "Thanks for your donation."
      : `I would like to donate ${strategy.currencySign}${tip} to charity.`;
  };

I hope you have noticed that we’re trying to directly extract non-view
code into separate places or abstract new mechanisms to reform it to be
more modular.

You can think of it this way: the React view is only one of the
consumers of your non-view code. For example, if you would build a new
interface – maybe with Vue or even a command line tool – how much code
can you reuse with your current implementation?

The benefits of having these layers

As demonstrated above, these layers brings us many advantages:

  1. Enhanced maintainability: by separating a component into distinct parts,
    it is easier to locate and fix defects in specific parts of the code. This can
    save time and reduce the risk of introducing new bugs while making changes.
  2. Increased modularity: the layered structure is more modular, which can
    make it easier to reuse code and build new features. Even in each layer, take
    views for example, tend to be more composable.
  3. Enhanced readability: it’s much easier to understand and follow the logic
    of the code. This can be especially helpful for other developers who are reading
    and working with the code. That’s the core of making changes to the
    codebase.
  4. Improved scalability: with reduced complixity in each individual module,
    the application is often more scalable, as it is easier to add new features or
    make changes without affecting the entire system. This can be especially
    important for large, complex applications that are expected to evolve over
    time.
  5. Migrate to other techstack: if we have to (even very unlikely in most
    projects), we can replace the view layer without changing the underlying models
    and logic. All because the domain logic is encapsulated in pure JavaScript (or
    TypeScript) code and isn’t aware of the existence of views.

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. Even
the patterns for constructing a headless service in the backend are also
valid in the frontend field. We can use layers in the frontend and have the
user interface as thin as possible, sink the logic into a supporting model
layer, and data access into another.

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.