Beyond the Component

Calling a JavaScript or TypeScript codebase a “React application” is a habit worth questioning. React is the view layer, not the application itself — just as we wouldn’t call a Java EE system a JSP application. Yet much of the code that actually runs these applications — data fetching, state transitions, formatting, routing — gets written inside React components and hooks because that’s where the developer happened to be working at the time.

This works fine for a Todo example or a one-form app. But when business logic moves to the frontend in earnest, components that mix every responsibility become hard to read and risky to modify. The way forward is to separate the view from the non-view logic, then split that non-view logic by its role in the application — precisely the layering approach proven in large GUI applications long before the web.

Where the Model Ends and the View Begins

React itself is deliberately unconcerned with anything beyond building user interfaces. A production frontend also needs a router, local storage, caches, network requests, third-party integrations, logging and performance tuning. Shoving all of that into JSX or a custom hook makes each file a jumble of levels of abstraction: one moment you’re reading a network request, the next a string trim, the next a navigation call.

Martin Fowler’s presentation-domain-data layering summarizes the remedy. Separating the three concerns lets you think about each relatively independently, increasing focus and maintainability. These are established patterns from the desktop GUI era — there’s no reason they should not apply to a modern React codebase.

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.

A Typical Evolution of Structure

Most React projects follow a predictable growth path, and each stage introduces its own limitation.

Everything in one component. Early on, the whole page lives in a single component. Rendering, event handling, configuration of third-party components and data fetching collapse into one file. There’s no place to focus — your eye jumps from generating list items to configuring UI widgets to parsing fetch responses.

Splitting views. The obvious first remedy is breaking the output HTML into child components. Now you can reason about each visual part in isolation. But besides rendering, components in a real app still need to call network requests, transform JSON into view-ready shapes, and collect form writes back to the server. That logic doesn’t belong in a user interface, and these components start carrying too much internal state.

Extracting custom hooks. Moving side effects and state into hooks is the next natural step. Custom hooks make scattered logic reusable, and views can remain presentational. However, inspection shows that the hooks still contain pure calculations mixed with state management — conversions, null checks and fallback decisions that have nothing to do with

A More Composable Payment Component

To see these refactoring patterns in action, let’s add a payment step to our ordering app. Customers see radio buttons for payment methods configured on the server; different countries may see different options. The UI is entirely data-driven: whatever the backend returns is rendered, and if nothing is returned we treat it as “pay in cash” by default.

A first pass at the Payment component might look familiar — network calls, formatting and rendering all in one place:

  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 problem isn’t the size, it’s that the component is trying to do too much at once. To change anything, you have to trace through four separate concerns at the same time — initializing the network request, mapping data to a local shape, rendering each method, and deciding what the Payment component itself displays.

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

That’s manageable in a hello-world example, but as the feature grows, this single component becomes a bottleneck. A cleaner split keeps view and non-view code separate: views tend to change more often, and smaller self-contained modules are easier to test, reason about and modify in isolation.

Extracting the Non-View Logic Into a Hook

React’s custom hooks are a natural fit for Extract Function refactoring. We can move state and data-fetching logic into a hook called usePaymentMethods (the use prefix signals to React that this function manages state):

  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,
    };
  };

The hook exposes a paymentMethods array of type LocalPaymentMethod as internal state, ready for rendering. The Payment component can then focus on presentation:

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

But looking at the iteration block over paymentMethods, there’s a missing abstraction. The rendering of each method is a concept in itself and deserves its own component. Smaller pure functions — where a given input always produces the same output — are easier to test, understand and reuse.

Splitting the View Into a Pure Subcomponent

Using the same extraction idea but for the JSX layer (a component is just a function, after all), we pull the list into a separate piece:

  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>
      ))}
    </>
  );

Now Payment is simpler, delegating the list rendering to PaymentMethods:

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods paymentMethods={paymentMethods} />
        <button>${amount}</button>
      </div>
    );
  };

PaymentMethods is a stateless pure component — essentially a formatting function for a list of payment options.

Modelling Data to Encapsulate Bespoke Logic

The view/non-view split works well so far. Still, some logic leaks remain. Inside the pure PaymentMethods component, we’re checking which method to mark as checked by default:

  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>
      ))}
    </>
  );

And in the hook, the data conversion happening inside methods.map is also a silent leak:

  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,
    };
  };

Both the method.provider === "cash" condition and the shape-mapping logic belong in a domain model. A simple PaymentMethod class can centralise the data and its behaviour:

  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";
    }
  }

This allows defining the default cash option on one place:

const payInCash = new PaymentMethod({ name: "cash" });

And converting the fetched data into these objects happens either inline during the map or in a dedicated small function:

  const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
    if (methods.length === 0) {
      return [];
    }
  
    const extended: PaymentMethod[] = methods.map(
      (method) => new PaymentMethod(method)
    );
    extended.push(payInCash);
  
    return extended;
  };

Inside the PaymentMethods view, the magic string comparison is replaced with the class getter:

  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>
      ))}
    </>
  );

What the New Structure Buys You

  • A testable domain object. All payment-method logic lives in one class with no UI concerns. Testing and modifying that logic in isolation is simpler than when it’s buried in a view.
  • A pure, reusable component. PaymentMethods receives a domain object array and renders it. Passing an onSelect callback doesn’t break purity — it never touches external state itself.
  • Clear navigation. Each requirement maps to a specific part of the codebase. New logic has an obvious home instead of being written inline wherever it’s first needed.

The examples here are intentionally simple enough to follow, but they reflect a familiar result: splitting the component structure into smaller pieces that each do one job well makes future modifications more predictable.

Donations and the round-up that breaks

The next feature request is a charity round-up prompt: for an order of $19.80, the customer is offered a $0.20 donation, and the button should reflect the adjusted total if they accept.

Before touching the code, the current structure has App.tsx as the entry, rendering a Payment component. Payment renders PaymentMethods, and the usePaymentMethods hook fetches remote data and maps it to PaymentMethod domain objects holding label and isDefaultChecked.

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

First attempt: state inside the view

Adding the donation feature to Payment requires a boolean agreeToDonate state linked to the checkbox. Computing the tip uses Math.floor to round the order amount down; the difference between the rounded-up value and the original order becomes tip.

  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 corresponding JSX is a checkbox and a short description:

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

This works for small components, and it's fine to keep cohesive pieces together. The watch out is when the component grows large enough that view and non-view concerns start to blur. The calculation logic here can be extracted into a custom hook that takes the original amount and returns total and tip whenever agreeToDonate changes.

  export const useRoundUp = (amount: number) => {
    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]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

In the view, calling the hook with the initial amount keeps state external. The updateAgreeToDonate function updates the hook value and triggers a re-render.

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <div>
          <label>
            <input
              type="checkbox"
              onChange={updateAgreeToDonate}
              checked={agreeToDonate}
            />
            <p>{formatCheckboxLabel(agreeToDonate, tip)}</p>
          </label>
        </div>
        <button>${total}</button>
      </div>
    );
  };

Extracting the label formatting into a helper, formatCheckboxLabel, further simplifies the component:

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

With state fully managed in useRoundUp, the Payment component becomes largely presentational. A hook acts like a state machine behind the view: a checkbox change event goes into the logic, a new state comes out, and React re-renders.

The donation checkbox itself can also become a purely presentational component:

  const DonationCheckbox = ({
    onChange,
    checked,
    content,
  }: DonationCheckboxProps) => {
    return (
      <div>
        <label>
          <input type="checkbox" onChange={onChange} checked={checked} />
          <p>{content}</p>
        </label>
      </div>
    );
  };

Payment then reads almost like plain HTML:

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

When round-up rules multiply

Expanding the business introduces new round-up rules. Japan needs rounding to the nearest hundred (0.1 Yen is too small for a donation), and Denmark needs the nearest ten. A simple fix adds a countryCode prop to Payment:

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

That prop is passed into the useRoundUp 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]
  );
  //...
};

The countryCode drives an if-else chain in a useEffect to set the rounding granularity, and the tip message formatting needs similar branches to switch currency signs:

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.`;
};

And the currency sign on the button's label must be handled as well:

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

The cost of scattered conditionals

This is the classic "shotgun surgery" smell. Adding one country code means touching multiple files—views, hooks, and label helpers—to keep behavior consistent. The conditional branches show up in every layer that cares about currency or rounding. Denmark, for instance, requires edits in several places:

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

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

The fix is structural: extract the varying behavior and use polymorphism to replace the scattered switches. Extract Class on the fluctuating properties lets you Replace Conditional with Polymorphism.

A strategy for country-specific logic

Countries differ in currency sign (a getCurrencySign method) and in round-up rules (getRoundUpAmount and getTip). These belong on a public interface:

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

  getTip(amount: number): number;
}

A concrete implementation, PaymentStrategyAU, holds the specifics:

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

Because functions are first-class citizens in JavaScript, the algorithm itself can be passed in instead of defining a subclass per country. With only one concrete implementation for now, an Inline Class keeps things lean:

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

UI components and hooks now depend on the PaymentStrategy class rather than on scattered country logic. At runtime, one instance of the strategy is easily swapped for another. The hook simplifies to:

  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,
    };
  };

The Payment component receives the strategy through props and hands it to the hook:

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

A few helper functions for label generation are extracted as a final cleanup:

  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.`;
  };

The goal of this restructuring is to keep the React view as just one consumer of the logic. Were you to build a Vue interface or a command-line tool instead, the strategy classes and utilities should be reusable without changes.

The network layer, decoupled

The usePaymentMethods hook is still doing two jobs: fetching and converting. Adding error handling and retries will quickly bloat it, and since hooks are React-specific, the logic can't be reused elsewhere.

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        setPaymentMethods(convertPaymentMethods(methods));
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

The conversion function, convertPaymentMethods, becomes a standalone utility. The fetching logic gets its own class that serves as an Anti-Corruption Layer, or a Gateway in the terminology of Patterns of Enterprise Application Architecture—an object encapsulating access to an external system so that adoption logic isn't scattered. A library like React Query can then take over the network plumbing:

  const fetchPaymentMethods = async () => {
    const response = await fetch("https://5a2f495fa871f00012678d70.mockapi.io/api/payment-methods?countryCode=AU");
    const methods: RemotePaymentMethod[] = await response.json();
  
    return convertPaymentMethods(methods)
  }

The usePaymentMethods hook degrades to a thin wrapper around that class:

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      fetchPaymentMethods().then(methods => setPaymentMethods(methods))
    }, []);
  
    return {
      paymentMethods,
    };
  };

This split keeps each part of the code focused, with most logic now living in non-view modules that are testable and reusable well beyond the React component.

Why layering pays off

Separating a React component into distinct layers yields practical benefits that compound as the codebase grows:

  1. Maintainability: When a defect appears, the layers narrow down where to look. Fixing a bug in one layer is less likely to introduce regressions elsewhere.
  2. Modularity: Each layer is independently reusable. Views, for example, become more composable when they don't carry logic or data-fetching responsibilities.
  3. Readability: Code organized by responsibility is easier to follow. A new developer can trace the flow from view to model to data access without untangling a single fat component.
  4. Scalability: Smaller, focused modules make it easier to extend the application piece by piece. New features can be added without rippling through the entire system — critical for large applications that evolve over time.
  5. Tech-stack flexibility: Because domain logic lives in pure JavaScript (or TypeScript) and has no awareness of the view layer, swapping the UI framework later is feasible without rewriting the underlying models and logic.

The takeaway

Treating a React application as an entirely new kind of software is a mistake. The patterns and principles that guided traditional user interface development still apply. Even the layering strategies used for headless backend services hold up in the frontend: keep the user interface thin, push the business logic into a dedicated model layer, and confine data access to yet another.

What you get in practice is a codebase you can understand one piece at a time. Combined with improved reusability, that makes evolving existing code far more manageable than it would be in a monolithic component structure.