Home

Frontend Three-Layer Architecture

Backend architecture has converged on the same patterns: controllers, services, repositories. The layers are clear, the responsibilities defined. But frontend? Frontend is chaos. Every project picks a different state management solution, folder structure, and testing strategy. There's no shared language.

This post is about applying three-layer thinking to the frontend. Not a prescription for a specific stack, but a mental model that works regardless of whether you're using Redux, React Query, Evolu, or something else entirely.

React is Just a View Library

It's easy to forget that React, at its core, is a library for building user interfaces. Not a framework. Not an application architecture. Just views.

Juntao Qiu makes this point beautifully in his article on modularizing React applications:

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.

This reframing matters. You're not building a "React app." You're building an application that uses React for its view layer. The same way a backend might use Express for HTTP handling but wouldn't be called an "Express application."

And yet, developers squeeze everything into React components. Data fetching in useEffect. Business logic in event handlers. Data transformation right next to JSX. The component becomes a dumping ground.

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.

The Three Layers

In backend, the layers are:

  • Controller: handles HTTP, validates input, returns responses
  • Service: contains business logic
  • Repository: talks to databases and external systems

In frontend, the equivalent is:

  • View: React components, what gets rendered
  • View Model: business logic, state transformations, what the view needs
  • Data Access: how you get and mutate data (API calls, local storage, SQLite)

Martin Fowler has written extensively about this 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.

View Layer
ComponentsJSXStyling
View Model Layer
Custom HooksState LogicTransformations
Data Access Layer
ReduxReact QueryEvoluAPI Clients

Dependencies flow downward. Each layer only knows about the layer below it.

tsx
// View - just renders what it's given
const UserProfile = () => {
  const { user, isLoading, updateName } = useUserProfile();

  if (isLoading) {
    return <Spinner />;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={() => updateName("New Name")}>Update</button>
    </div>
  );
};

// View Model - contains the logic
const useUserProfile = () => {
  const { data: user, isLoading } = useUser();
  const { mutate } = useUpdateUser();

  const updateName = (name: string) => {
    if (name.length < 2) {
      return;
    }
    mutate({ id: user.id, name });
  };

  return { user, isLoading, updateName };
};

// Data Access - talks to the API
const useUser = () => useQuery({ queryKey: ["user"], queryFn: fetchUser });
const useUpdateUser = () => useMutation({ mutationFn: updateUser });

The view doesn't know where data comes from. The view model doesn't know how the view renders it. The data access layer doesn't know what the data is used for. Same separation of concerns, different context.

State Management is a Data Access Layer

This is the insight that took me too long to understand: your state management solution is just a data access layer. Redux, React Query, Evolu, Zustand, contexts - they're all just different ways of storing and accessing data.

Redux is a fully in-memory store. You dispatch actions, reducers update state, and selectors read it. It's essentially a client-side database with a very specific update mechanism.

React Query is aggressive caching with server synchronisation. It stores server state locally, handles refetching, and gives you loading/error states for free.

Evolu uses a SQLite database running on OPFS. You write SQL queries, get reactive updates, and it syncs across devices. It's a literal database in the browser.

Each has different characteristics:

typescript
// Redux - in-memory, synchronous reads, action-based writes
const user = useSelector((state) => state.users.current);
dispatch(updateUserName({ id: user.id, name: "New Name" }));

// React Query - cached server state, async reads, mutation-based writes
const { data: user } = useQuery({ queryKey: ["user"] });
mutate({ id: user.id, name: "New Name" });

// Evolu - SQLite, reactive queries, direct writes
const {
  rows: [user],
} = useQuery(userQuery);
update("users", { id: user.id }, { name: "New Name" });

The syntax differs, the mental models differ, but the role in your architecture is the same. They're all data access layers.

Never Couple Business Logic to Components

This is the hill I will die on: business logic has no place in React components.

Not in the component body. Not in event handlers. Not in useEffect. Not even in custom hooks that are tightly coupled to specific components.

Here's what coupling looks like:

tsx
// This is a disaster waiting to happen
const PaymentForm = () => {
  const [amount, setAmount] = useState(0);
  const [agreeToDonate, setAgreeToDonate] = useState(false);

  // Business logic embedded in component
  const roundedAmount = agreeToDonate ? Math.floor(amount + 1) : amount;
  const tip = Math.floor(amount + 1) - amount;

  // More business logic in handlers
  const handleSubmit = () => {
    if (amount < 0) { return; }
    if (agreeToDonate && tip > amount * 0.1) { return; }
    // ... validation continues
    processPayment(roundedAmount);
  };

  return (/* 50 lines of JSX mixed with logic */);
};

The problems compound:

  • Testing requires rendering. You can't test the round-up logic without mounting a React component.
  • Reuse is impossible. Need the same calculation elsewhere? Copy-paste or extract.
  • Changes ripple everywhere. The Fowler article calls this "shotgun surgery" - a single logical change requires modifications across multiple files.
if (countryCode === "JP")
UserProfile.tsx
← logic here
useUserData.ts
← logic here
UserAvatar.tsx
formatters.ts
← logic here
UserSettings.tsx
← logic here
api.ts

Shotgun surgery: One logical change requires edits across multiple files.

The View Model Separation

The fix is a strict view model layer. Components receive data and callbacks. They render. That's it.

tsx
// Component is ONLY a view
const PaymentForm = () => {
  const vm = usePaymentFormViewModel();

  return (
    <form onSubmit={vm.handleSubmit}>
      <input value={vm.amount} onChange={(e) => vm.setAmount(e.target.value)} />
      <DonationCheckbox
        checked={vm.agreeToDonate}
        onChange={vm.toggleDonation}
        label={vm.donationLabel}
      />
      <button>{vm.submitLabel}</button>
    </form>
  );
};

// All logic lives in the view model
const usePaymentFormViewModel = () => {
  const [amount, setAmount] = useState(0);
  const [agreeToDonate, setAgreeToDonate] = useState(false);
  const { mutate } = useProcessPayment();

  const calculation = PaymentLogic.calculateDonation(amount, agreeToDonate);

  return {
    amount,
    setAmount: (val: string) => setAmount(parseFloat(val) || 0),
    agreeToDonate,
    toggleDonation: () => setAgreeToDonate((prev) => !prev),
    donationLabel: PaymentLogic.formatDonationLabel(calculation),
    submitLabel: PaymentLogic.formatSubmitLabel(calculation),
    handleSubmit: () => {
      if (PaymentLogic.canSubmit(amount, calculation)) {
        mutate({ amount: calculation.total });
      }
    },
  };
};

The component knows nothing about payment calculations. It doesn't know what "agreeing to donate" means mathematically. It just renders what it's given.

Notice how PaymentLogic is referenced but never defined in this code. That's intentional. PaymentLogic is pure TypeScript - a plain object or module with zero React dependencies:

typescript
// PaymentLogic.ts - no React imports, no hooks, no lifecycle
export const PaymentLogic = {
  calculateDonation: (amount: number, agreeToDonate: boolean) => {
    const roundedAmount = agreeToDonate ? Math.floor(amount + 1) : amount;
    const tip = parseFloat((Math.floor(amount + 1) - amount).toPrecision(10));
    return { total: roundedAmount, tip };
  },

  formatDonationLabel: (calc: { tip: number }) =>
    `Round up $${calc.tip} for charity`,

  formatSubmitLabel: (calc: { total: number }) => `Pay $${calc.total}`,

  canSubmit: (amount: number, calc: { total: number }) =>
    amount > 0 && calc.total > 0,
};

The view model hook isn't where logic lives - it's an orchestration layer. It handles React's lifecycle concerns: managing useState, calling mutations, wiring up callbacks. But the actual calculations? Those are delegated to PaymentLogic.

This separation is critical. PaymentLogic doesn't know about:

  • React's render cycle
  • When state updates
  • How mutations are triggered
  • Component mounting or unmounting

It's just functions that take inputs and return outputs. You can call these functions from anywhere - a React component, a Node.js script, a test file, a different framework entirely. Business logic should never depend on the React lifecycle.

The view model is the adapter between React's world (hooks, state, effects) and your pure business logic. React does React things. Logic does logic things. They meet in the view model but never intermingle.

View
PaymentForm.tsx
calls hook
View Model
usePaymentForm.ts
delegates to
Business Logic
PaymentLogic.ts
fetches from
Data Access
usePaymentApi.ts

The view model orchestrates, but the real work happens in pure TypeScript logic and data access hooks.

Dependency Injection via Props

You can take this further. If your view model hook is a dependency, inject it.

tsx
// The hook is a prop
type PaymentFormProps = {
  useViewModel?: () => PaymentFormViewModel;
};

const PaymentForm = ({
  useViewModel = usePaymentFormViewModel,
}: PaymentFormProps) => {
  const vm = useViewModel();

  return <form onSubmit={vm.handleSubmit}>{/* ... */}</form>;
};

// In tests, inject a mock
const mockViewModel = () => ({
  amount: 100,
  setAmount: vi.fn(),
  agreeToDonate: false,
  toggleDonation: vi.fn(),
  donationLabel: "Donate $1",
  submitLabel: "Pay $100",
  handleSubmit: vi.fn(),
});

render(<PaymentForm useViewModel={mockViewModel} />);

This pattern treats hooks as injectable dependencies. The component doesn't know or care which hook provides its view model. You can swap implementations for testing, for different contexts, or for A/B testing different behaviors.

It's the same dependency injection pattern from backend development, adapted for React's hook system.

Business Logic as a Library

The core insight: treat your business logic like a library. An internal API that your view models call. Something you could publish as a package if you wanted to.

typescript
// Pure business logic - no React, no state management
export const PaymentLogic = {
  calculateDonation: (amount: number, agreeToDonate: boolean) => {
    const roundedAmount = agreeToDonate ? Math.floor(amount + 1) : amount;
    const tip = parseFloat((Math.floor(amount + 1) - amount).toPrecision(10));
    return { total: roundedAmount, tip };
  },

  formatDonationLabel: (calculation: { tip: number }) => {
    return `Round up $${calculation.tip} for charity`;
  },

  formatSubmitLabel: (calculation: { total: number }) => {
    return `Pay $${calculation.total}`;
  },

  canSubmit: (amount: number, calculation: { total: number }) => {
    return amount > 0 && calculation.total > 0;
  },
};

This follows the adapter pattern from backend development. Your business logic doesn't know about React Query, Redux, or any specific framework. It's just TypeScript. It can be tested with pure unit tests. No mocking hooks, no rendering components, no waiting for effects.

The Fowler article demonstrates this with a PaymentMethod class that encapsulates all payment-related logic:

typescript
class PaymentMethod {
  constructor(private 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";
  }
}

The logic is centralised. The view just calls method.label and method.isDefaultMethod. No conditional logic in JSX. No string formatting in components.

The Gateway Pattern

When your data access layer talks to external systems, wrap it in a gateway. This is an anti-corruption layer that isolates your domain from external data shapes.

typescript
// Gateway - converts external format to internal format
const fetchPaymentMethods = async (): Promise<PaymentMethod[]> => {
  const response = await fetch("/api/payment-methods");
  const methods: RemotePaymentMethod[] = await response.json();

  return methods.map((m) => new PaymentMethod(m));
};

// Hook uses the gateway
const usePaymentMethods = () => {
  const [methods, setMethods] = useState<PaymentMethod[]>([]);

  useEffect(() => {
    fetchPaymentMethods().then(setMethods);
  }, []);

  return { methods };
};

The gateway handles the conversion. The hook manages the state. The view model shapes it for the view. The component renders. Clean separation at every level.

Subscription Hygiene

The most important rule of frontend state management: only subscribe to state when the effects of that subscription are rendered.

Every subscription is a potential re-render. When you useSelector, useQuery, or useSubscription, you're telling React "re-render this component when this data changes."

If you're subscribing to data you don't render, you're wasting cycles.

tsx
// Bad - subscribes to entire user object
const Avatar = () => {
  const user = useSelector((state) => state.users.current);
  return <img src={user.avatarUrl} />;
};

// Good - subscribes only to what's rendered
const Avatar = () => {
  const avatarUrl = useSelector((state) => state.users.current.avatarUrl);
  return <img src={avatarUrl} />;
};

This is why Redux emphasises selector memoisation. This is why React Query has select options. This is why Zustand has slice selectors. The tools give you fine-grained subscription control because they know it matters.

Only select what you need.

typescript
const useUserProfileViewModel = () => {
  // don't do this
  const user = useSelector(selectCurrentUser);

  // do this instead
  const avatarUrl = useSelector(selectCurrentUserAvatarUrl);

  return {
    name: user.name,
    avatarUrl: user.avatarUrl,
    // Don't return the entire user object if the view doesn't need it
  };
};

React is One Big Side Effect

Here's something that reframed how I think about React: React itself is a side effect system.

A pure function takes inputs and returns outputs. No external effects. But React components take props and produce DOM mutations. That's a side effect. The entire render cycle is about turning state into visible changes. Every piece of functionality you build is inherently asynchronous, and wrapped in useEffect. You're guaranteed to have a side effect in any functionality that actually does something.

This is why state machines and effects feel natural in React. Redux actions are events. Reducers are state transitions. Effects are side effects that happen in response to state changes. It's the same model.

typescript
// This is essentially a state machine
const userReducer = (state, action) => {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, isLoading: true };
    case "FETCH_SUCCESS":
      return { ...state, isLoading: false, data: action.payload };
    case "FETCH_ERROR":
      return { ...state, isLoading: false, error: action.payload };
  }
};

The problem is when business logic leaks into effects. You start putting validation in useEffect. Calculations in event handlers. Business rules scattered across components.

Alternatively, you start treating your state machine as a global store with subscriptions. Your actions and reducers are simple setters, and no longer represent the business logic.

I don't use Redux often anymore, because it's pattern is designed to tightly couple business logic to state via the state machine. This is perfectly valid, but its not the pattern I prefer. I'd much rather use react query for caching, and keep my business logic orchestrated in the view model. In part, its why I'm such a fan of Evolu. Using SQLite in the browser brings an inherent requirement to decouple the business logic from the state management solution.

Keep the effects for what they're meant for: synchronising with external systems. The business logic belongs elsewhere.

Product Isolation, Not Feature Isolation

A lot of frontend codebases organise by feature:

filesystem
src/
  features/
    users/
    posts/
    comments/
    notifications/

The theory is each feature is isolated. You can work on users/ without touching posts/. You could theoretically extract a feature into its own package.

In practice, this falls apart. Users have posts. Posts have comments. Comments have notifications. Everything depends on everything else. You end up hoisting shared code to a common folder, and that folder grows until it contains most of your logic.

Features aren't isolatable because entities aren't isolatable.

Consider any backend you've worked with. There's a dependency graph between entities. Users → Posts → Comments → Likes. You can't remove Posts without breaking everything downstream. The entities to the right depend on the entities to the left.

The same is true in frontend. Your user profile component needs user data, which needs auth context, which needs API configuration. The dependency chain is unavoidable.

Product-Level Boundaries

Instead of isolating by feature, isolate by product.

At Coalesce, we have two distinct products:

  • Build: the workspace where you define data pipelines
  • Deploy: the system that runs those pipelines

These are genuinely independent, whether built that way or not. Different user journeys. Different mental models. You could theoretically split them into separate apps.

This is where isolation makes sense. If you were doing micro-frontends, this is where you'd draw the line. Each product area is independent enough that it could be its own codebase.

filesystem
src/
  products/
    build/
      components/
      hooks/
      logic/
    deploy/
      components/
      hooks/
      logic/
  core/
    auth/
    api/
    shared-components/

Within each product, you'll have inter-dependencies. That's fine. The components in build/ will call hooks in build/ which will use logic from build/. Entities within the build product are inherently dependent on each other.

But between products, dependencies should be minimal. If deploy/ needs something from build/, that's probably a sign the thing should be in core/. If you were using micro-frontends, this would be the shared package that every frontend imports. The code that you're comfortable running multiple instances of because it is so foundational.

Core vs Isolatable

Some code is so foundational that every part of your app needs it. Authentication. API clients. Design system components. Theme configuration.

This is your core. It's not a feature. It's infrastructure.

typescript
// Core - used everywhere
export const useAuth = () => {
  /* ... */
};
export const useApi = () => {
  /* ... */
};
export const Button = () => {
  /* ... */
};
export const Modal = () => {
  /* ... */
};

// Not core - specific to a product area
export const usePipelineBuilder = () => {
  /* ... */
};
export const useDeploymentStatus = () => {
  /* ... */
};

If you went to micro-frontends, core would become a shared package that every frontend imports. It's the foundational layer everything else builds on.

Don't try to isolate your core. Don't pretend you could remove authentication without breaking everything. Accept that some code is structural and will be depended upon by everything.

The things you can isolate are product areas that happen to share infrastructure. The isolated products depend on core, but not on each other.

The Testing Reality

Testing in React sucks. Component tests are painful:

  • Async state updates need act() wrappers
  • Effects run at unexpected times
  • Mocking hooks requires library-specific tricks
  • Snapshot tests break on every styling change

The more logic you can move out of React, the easier testing becomes.

typescript
// This is easy to test
describe("PaymentLogic.calculateDonation", () => {
  it("rounds up when donation agreed", () => {
    const result = PaymentLogic.calculateDonation(19.80, true);
    expect(result.total).toBe(20);
    expect(result.tip).toBe(0.20);
  });

  it("returns original amount when not donating", () => {
    const result = PaymentLogic.calculateDonation(19.80, false);
    expect(result.total).toBe(19.80);
  });
});

// This is painful to test
describe("PaymentForm component", () => {
  it("shows correct donation amount", async () => {
    render(<PaymentForm />);
    // Mock the hook, wait for renders, find elements, assert state...
    // Fight with act() warnings...
    // Wonder why the test is flaky in CI...
  });
});

Pure TypeScript functions test in milliseconds. Component tests take seconds and break when unrelated things change. Move logic out of components and into testable functions.

When you do need to test components, the dependency injection pattern pays off. Inject mock view models. Test that the component renders what the view model provides. Don't test business logic through component tests.

Conclusion

Frontend architecture doesn't have to be chaos. The same three-layer thinking that works for backends works for frontends:

  • View: React components that render data
  • View Model: hooks that contain logic and expose what views need
  • Data Access: your state management solution, whatever it is

The critical rule: never couple business logic to components. Extract it to pure TypeScript. Treat it like an internal library. Test it without React. Inject view model hooks as dependencies when you need flexibility.

Isolate at the product level, not the feature level. Accept that entities have dependencies. Don't pretend you can extract arbitrary features when everything depends on everything else.

And remember: your state management is just a data access layer. Redux, React Query, Evolu - different tools, same architectural role. The view model is where you separate "how we store data" from "what the view needs."

The architecture that ships is better than the architecture that's theoretically pure.


For a deeper dive into these patterns with extensive code examples, I highly recommend Juntao Qiu's Modularizing React Applications with Established UI Patterns on Martin Fowler's blog.