Home / Notebooks / Frontend Development
Frontend Development
intermediate

Frontend Architecture & Testability

Design patterns for building frontend applications where logic is separated from UI — making code testable, maintainable, and scalable

April 28, 2026
Updated regularly

Frontend Architecture & Testability

The same problem exists in frontend as in backend: logic tangled with delivery makes code untestable and hard to change. The solution is the same too — separate concerns so that business logic can be verified without touching the UI.

> The goal is to push logic toward pure functions and away from the component tree.

---

The Core Problem

When logic lives inside components, you can only test it by rendering the UI — slow, fragile, and overkill for a simple calculation.

// ❌ BAD: Logic tangled inside the component
function CartPage() {
  const [items, setItems] = useState<CartItem[]>([]);

  const total = items.reduce((sum, item) => {
    const discounted = item.price * (1 - item.discountRate);
    return sum + discounted * item.quantity;
  }, 0);

  const tax = total * 0.11;
  const grandTotal = total + tax;

  return <div>Total: {grandTotal}</div>;
}
// To test grandTotal, you must render CartPage — just to verify math.
// ✅ GOOD: Logic extracted to a pure function
function calculateCartTotal(items: CartItem[]): CartTotal {
  const subtotal = items.reduce((sum, item) => {
    const discounted = item.price * (1 - item.discountRate);
    return sum + discounted * item.quantity;
  }, 0);
  const tax = subtotal * 0.11;
  return { subtotal, tax, grandTotal: subtotal + tax };
}

function CartPage() {
  const [items, setItems] = useState<CartItem[]>([]);
  const { grandTotal } = calculateCartTotal(items);
  return <div>Total: {grandTotal}</div>;
}
// calculateCartTotal is a plain function — testable with zero UI setup.

---

Pattern 1 — Container / Presenter

Split every feature into two components: one that thinks, one that renders.

RoleResponsibilityTestable with
ContainerFetches data, holds state, handles eventsUnit tests on logic
PresenterRenders props into UI, no logicSnapshot / render tests
// ========== PRESENTER: Pure render, no logic ==========
type UserCardProps = {
  name: string;
  email: string;
  isOnline: boolean;
  onLogout: () => void;
};

function UserCard({ name, email, isOnline, onLogout }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      <span>{isOnline ? "Online" : "Offline"}</span>
      <button onClick={onLogout}>Logout</button>
    </div>
  );
}
// Test: render with props, assert output. No API calls, no state.

// ========== CONTAINER: Logic and data, no JSX ==========
function UserCardContainer({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  const handleLogout = () => {
    authService.logout();
  };

  if (!user) return <Spinner />;

  return (
    <UserCard
      name={user.name}
      email={user.email}
      isOnline={user.status === "active"}
      onLogout={handleLogout}
    />
  );
}
// ========== Testing the Presenter ==========
import { render, screen } from "@testing-library/react";

test("shows Offline badge when isOnline is false", () => {
  render(
    <UserCard
      name="Yudi"
      email="yudi@example.com"
      isOnline={false}
      onLogout={() => {}}
    />
  );
  expect(screen.getByText("Offline")).toBeInTheDocument();
});

test("calls onLogout when button is clicked", async () => {
  const onLogout = jest.fn();
  const { user } = renderWithUser(
    <UserCard name="Yudi" email="yudi@example.com" isOnline={true} onLogout={onLogout} />
  );
  await user.click(screen.getByRole("button", { name: /logout/i }));
  expect(onLogout).toHaveBeenCalledTimes(1);
});

---

Pattern 2 — Custom Hooks as Logic Layer

Extract all stateful logic into custom hooks. The component becomes a thin rendering shell.

// ❌ BAD: Form logic lives inside the component
function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!email.includes("@")) { setError("Invalid email"); return; }
    setLoading(true);
    try {
      await authService.login(email, password);
    } catch {
      setError("Login failed");
    } finally {
      setLoading(false);
    }
  };

  return (/* JSX */);
}
// Testing validation or submit flow requires a full render + event simulation.
// ✅ GOOD: Logic extracted to a custom hook
function useLoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const validate = () => {
    if (!email.includes("@")) { setError("Invalid email"); return false; }
    return true;
  };

  const submit = async () => {
    if (!validate()) return;
    setLoading(true);
    try {
      await authService.login(email, password);
    } catch {
      setError("Login failed");
    } finally {
      setLoading(false);
    }
  };

  return { email, setEmail, password, setPassword, error, loading, submit };
}

// Component becomes trivial
function LoginForm() {
  const form = useLoginForm();
  return (
    <form onSubmit={(e) => { e.preventDefault(); form.submit(); }}>
      <input value={form.email} onChange={(e) => form.setEmail(e.target.value)} />
      <input type="password" value={form.password} onChange={(e) => form.setPassword(e.target.value)} />
      {form.error && <p>{form.error}</p>}
      <button disabled={form.loading}>Login</button>
    </form>
  );
}
// ========== Testing the hook directly ==========
import { renderHook, act } from "@testing-library/react";

test("sets error when email has no @", async () => {
  const { result } = renderHook(() => useLoginForm());

  act(() => { result.current.setEmail("notanemail"); });
  await act(() => result.current.submit());

  expect(result.current.error).toBe("Invalid email");
});
// No component rendered — just the logic.

---

Pattern 3 — Pure Functions for Business Logic

Pure functions are the easiest unit to test: same input always produces same output, no side effects.

// ========== Extract calculations into pure functions ==========

// ❌ BAD: Calculation inside component
function PricingSummary({ plan, users }: Props) {
  let price = plan === "pro" ? 29 : 9;
  if (users > 10) price = price * users * 0.9;
  else price = price * users;
  const vat = price * 0.11;
  // ...
}

// ✅ GOOD: Pure function, testable in isolation
function calculatePlanPrice(plan: "basic" | "pro", users: number): PriceSummary {
  const basePerUser = plan === "pro" ? 29 : 9;
  const subtotal = users > 10
    ? basePerUser * users * 0.9
    : basePerUser * users;
  return {
    subtotal,
    vat: subtotal * 0.11,
    total: subtotal * 1.11,
  };
}

function PricingSummary({ plan, users }: Props) {
  const price = calculatePlanPrice(plan, users);
  return <div>Total: {price.total}</div>;
}
// ========== Unit tests: fast, zero overhead ==========
import { calculatePlanPrice } from "./pricing";

test("basic plan, under 10 users: no bulk discount", () => {
  const result = calculatePlanPrice("basic", 5);
  expect(result.subtotal).toBe(45); // 9 * 5
});

test("pro plan, over 10 users: 10% bulk discount", () => {
  const result = calculatePlanPrice("pro", 12);
  expect(result.subtotal).toBeCloseTo(313.2); // 29 * 12 * 0.9
});

test("total includes 11% VAT", () => {
  const result = calculatePlanPrice("basic", 1);
  expect(result.total).toBeCloseTo(9 * 1.11);
});

---

Pattern 4 — Ports & Adapters (Hexagonal) Applied to Frontend

Treat external dependencies (APIs, localStorage, cookies) as adapters behind an interface. Core logic never imports them directly.

// ========== Define the Port (interface) ==========
interface AuthRepository {
  login(email: string, password: string): Promise<User>;
  logout(): Promise<void>;
  getCurrentUser(): Promise<User | null>;
}

// ========== Real Adapter (production) ==========
class ApiAuthRepository implements AuthRepository {
  async login(email: string, password: string) {
    const res = await fetch("/api/auth/login", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });
    return res.json();
  }
  async logout() { await fetch("/api/auth/logout", { method: "POST" }); }
  async getCurrentUser() { return fetch("/api/auth/me").then((r) => r.json()); }
}

// ========== Fake Adapter (testing) ==========
class FakeAuthRepository implements AuthRepository {
  private users: User[] = [{ id: "1", name: "Yudi", email: "yudi@example.com" }];

  async login(email: string) {
    const user = this.users.find((u) => u.email === email);
    if (!user) throw new Error("Invalid credentials");
    return user;
  }
  async logout() {}
  async getCurrentUser() { return this.users[0]; }
}
// ========== Hook depends on the port, not the adapter ==========
function useAuth(repo: AuthRepository) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    const loggedIn = await repo.login(email, password);
    setUser(loggedIn);
  };

  return { user, login };
}

// Production: inject real adapter
const auth = useAuth(new ApiAuthRepository());

// Tests: inject fake adapter — no network, no mocking fetch
const auth = useAuth(new FakeAuthRepository());

---

Testing Layers

A well-structured frontend has three layers of tests, each with a different cost/confidence trade-off.

Unit Tests         → Pure functions and custom hooks in isolation
                     Fast (~ms), many, cover all edge cases

Integration Tests  → Components rendered with real hooks + fake adapters
                     Medium speed, verify the wiring is correct

E2E Tests          → Full browser flow against a real (or staging) server
                     Slow, few, cover critical user journeys only
// ========== Unit: pure function ==========
test("calculateDiscount returns 0 for empty cart", () => {
  expect(calculateDiscount([])).toBe(0);
});

// ========== Integration: component + hook + fake repo ==========
test("LoginForm shows error message on bad credentials", async () => {
  const fakeRepo = new FakeAuthRepository();
  render(<LoginForm authRepo={fakeRepo} />);

  await userEvent.type(screen.getByLabelText("Email"), "wrong@example.com");
  await userEvent.type(screen.getByLabelText("Password"), "wrongpass");
  await userEvent.click(screen.getByRole("button", { name: /login/i }));

  expect(await screen.findByText("Invalid credentials")).toBeInTheDocument();
});

// ========== E2E: Playwright, real browser ==========
test("user can log in and see dashboard", async ({ page }) => {
  await page.goto("/login");
  await page.fill('[name="email"]', "yudi@example.com");
  await page.fill('[name="password"]', "correctpassword");
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL("/dashboard");
});
LayerVolumeWhen it fails
Unit~70%Logic is wrong
Integration~20%Components are wired incorrectly
E2E~10%Critical flows are broken end-to-end
---

Common Mistakes

Testing Implementation Details

// ❌ BAD: Tests internal state, not behavior
test("sets loading to true on submit", () => {
  const { result } = renderHook(() => useLoginForm());
  act(() => result.current.submit());
  expect(result.current.loading).toBe(true); // Brittle — internal detail
});

// ✅ GOOD: Tests what the user sees
test("shows spinner while submitting", async () => {
  render(<LoginForm />);
  await userEvent.click(screen.getByRole("button", { name: /login/i }));
  expect(screen.getByRole("status")).toBeInTheDocument(); // Spinner visible
});

Fetching Data Inside Components You Want to Unit Test

// ❌ BAD: Component fetches directly — impossible to unit test
function ProductList() {
  useEffect(() => {
    fetch("/api/products").then(...);
  }, []);
}

// ✅ GOOD: Accept data as props or inject the fetcher
function ProductList({ products }: { products: Product[] }) {
  return products.map((p) => <ProductCard key={p.id} product={p} />);
}
// ProductList is now a pure presenter — testable with any array of products.

One Giant Component

// ❌ BAD: 300-line component with state, fetch, validation, and render mixed
function CheckoutPage() { /* ... everything ... */ }

// ✅ GOOD: Decomposed responsibilities
function useCheckout(repo: OrderRepository) { /* logic hook */ }
function validateOrder(order: Order): ValidationResult { /* pure function */ }
function CheckoutSummary({ order }: Props) { /* presenter */ }
function CheckoutPage({ repo }: Props) {
  const checkout = useCheckout(repo);
  const validation = validateOrder(checkout.order);
  return <CheckoutSummary order={checkout.order} />;
}

---

Quick Reference

PatternWhat it solvesTest with
Container / PresenterLogic mixed with renderRender Presenter with props; unit test Container logic
Custom HookStateful logic inside componentrenderHook from Testing Library
Pure FunctionCalculations inside componentPlain jest / vitest — no DOM needed
Ports & AdaptersHard dependency on fetch/APISwap real adapter for fake in tests
---

Tools

# Testing Library — render components, simulate user events
npm install --save-dev @testing-library/react @testing-library/user-event

# Jest or Vitest — test runner
npm install --save-dev vitest           # preferred for Vite/Next.js projects
npm install --save-dev jest             # classic option

# Playwright — E2E browser testing
npm install --save-dev @playwright/test
npx playwright install

# Running tests
npx vitest                              # unit + integration (watch mode)
npx vitest run                          # single run
npx playwright test                     # E2E
npx vitest --coverage                   # with coverage report

---

Resources

  • Testing Library Docs
  • Vitest Documentation
  • Playwright Documentation
  • Kent C. Dodds — Testing Implementation Details
  • Martin Fowler — Presentation Domain Data Layering
  • Topics

    FrontendArchitectureTestingReactDesign Patterns

    Found This Helpful?

    If you have questions or suggestions for improving these notes, I'd love to hear from you.