All writing
3 min read

Building Better React Applications

Most React codebases do not fail because of React. They fail because of where the state lives, what owns the data fetching, and how the folder tree lies about the product.

  • React
  • TypeScript
  • Architecture
Building Better React Applications

Every fintech frontend I have inherited has had the same shape of problem, and it was never the framework. It was three decisions made early and never revisited: where state lives, who owns the network, and whether the folder tree describes the product or the framework.

None of those show up in a code review of a single file. They show up six months later as a pull request that has to touch eleven files to rename one field.

Colocate until it hurts#

The default should be that a feature owns everything it needs. Not a global components/ folder with forty files and no owner.

code
src/features/payouts/
  PayoutsPage.tsx
  PayoutRow.tsx
  usePayouts.ts
  payouts.api.ts
  payouts.types.ts

The test I apply: if I delete this folder, does the build still typecheck? If the answer is no, the feature is not really a unit.

Shared code should be a promotion, not an assumption. Something moves up to shared/ when a second feature needs it, and not before. Premature sharing is the most common cause of a component with nine boolean props.

Let the server own server state#

The single biggest simplification in the last few years of React has been accepting that server state is not client state. It has different failure modes: it can be stale, it can be missing, it can be slow, and it can change under you.

Hand-rolling that in useState + useEffect means re-implementing caching, deduplication, retries, cancellation and race-condition handling by hand — usually badly, usually in a component that also renders a table.

tsx
function useMerchant(id: string) {
  return useQuery({
    queryKey: ["merchant", id],
    queryFn: ({ signal }) => api.merchant(id, { signal }),
    staleTime: 30_000,
  });
}

Client state is the small stuff: which drawer is open, which row is selected, what the user typed. Keep it local, keep it obvious, and do not put it in a global store just because a modal three levels down needs it. Lift it one level instead.

Types are the interface, not decoration#

In a payments product the API contract is the product. A status field with four possible values that the backend can extend without telling you is a runtime crash waiting to happen.

ts
export type PaymentStatus =
  | "pending"
  | "authorized"
  | "captured"
  | "settled"
  | "refunded"
  | "failed";

// Exhaustive by construction: add a status and this stops compiling.
export function isFinal(status: PaymentStatus): boolean {
  switch (status) {
    case "settled":
    case "refunded":
    case "failed":
      return true;
    case "pending":
    case "authorized":
    case "captured":
      return false;
    default: {
      const unreachable: never = status;
      return unreachable;
    }
  }
}

That never at the bottom has caught more production bugs for me than any test suite. It converts "the backend added a status" from a silent wrong render into a compile error.

Four rules I actually enforce#

  1. One source of truth per piece of data. If two places can set it, one of them is a cache.
  2. No useEffect for derived data. If a value can be computed during render, computing it in an effect guarantees one frame of wrong UI.
  3. Every async boundary has three visible states. Loading, empty, error. An unexplained blank panel is a bug report waiting to be filed.
  4. A feature folder has one entry point. Everything else is private to it.

The interface is the product. Everything else is an implementation detail that should be free to change.

What this buys you#

None of this is clever. It is deliberately boring, and that is the point: boring structure is what lets you spend your attention on the part users actually notice — how the thing feels when a payment fails at 2am and someone has to fix it from a phone.

The payoff is measurable in review time. When a change to a payout flow touches three files in one folder, reviewers can hold the whole change in their head. When it touches eleven files across five folders, they approve it and hope.

I have shipped both. The first one is better.