Home / Articles

Three TypeScript Patterns That Improve React App Architecture

Learn how the Repository, Observer, and Builder patterns use TypeScript's type system to create cleaner, more maintainable React and Next.js codebases.

4 min read956 words

This article is published in English.

Writing TypeScript doesn't mean using it well

Plenty of developers slap type annotations on their variables and call it a day. But the real strength of TypeScript lies elsewhere — in the structural patterns that separate a fragile codebase from one that grows gracefully over time.

Having spent time building production apps with React and Next.js, a handful of patterns stand out as genuine game-changers. These aren't abstract textbook exercises — they're solutions to problems you'll actually run into.

1. Repository Pattern — Separate Your Data Fetching from Everything Else

When your components call fetch() directly, you're setting yourself up for a painful rewrite the moment an API changes shape.

The Repository Pattern wraps data access behind a clean interface:

// repositories/userRepository.ts
interface UserRepository {
  getById(id: string): Promise<User>;
  getAll(): Promise<User[]>;
}

export class ApiUserRepository implements UserRepository {
  async getById(id: string): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  }

  async getAll(): Promise<User[]> {
    const res = await fetch('/api/users');
    return res.json();
  }
}

With this in place, your components depend on an abstraction rather than a concrete implementation. That means you can swap ApiUserRepository for a mock version in your tests without touching any UI code.

2. Observer Pattern — Reactive State Without the Redux Overhead

Redux gets the job done, but for state that isn't especially complex, it can feel like using a sledgehammer for a small task. A lightweight, class-based Observer implementation built on a typed event emitter offers a simpler alternative:

// utils/eventBus.ts
type EventMap = {
  'user:loggedIn': { userId: string };
  'cart:updated': { itemCount: number };
};

class TypedEventBus {
  private listeners: Partial<{
    [K in keyof EventMap]: ((payload: EventMap[K]) => void)[]
  }> = {};

  on<K extends keyof EventMap>(event: K, cb: (payload: EventMap[K]) => void) {
    (this.listeners[event] ??= []).push(cb);
  }

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
    this.listeners[event]?.forEach(cb => cb(payload));
  }
}

export const eventBus = new TypedEventBus();

Everything here is strongly typed — no any types hiding in the shadows. That means there's no ambiguity about what shape of payload a given listener should expect.

3. Builder Pattern — Bring Order to Complex Object Creation

If you've ever wrestled with constructing filter objects, API request configurations, or form schemas full of nested conditionals, the Builder Pattern brings immediate clarity:

// builders/queryBuilder.ts
class QueryBuilder {
  private params: Record<string, string> = {};

  withPage(page: number) {
    this.params['page'] = String(page);
    return this;
  }

  withLimit(limit: number) {
    this.params['limit'] = String(limit);
    return this;
  }

  withSearch(term: string) {
    if (term.trim()) this.params['q'] = term;
    return this;
  }

  build(): string {
    return new URLSearchParams(this.params).toString();
  }
}

// Usage
const query = new QueryBuilder()
  .withPage(1)
  .withLimit(20)
  .withSearch('typescript')
  .build();
// → "page=1&limit=20&q=typescript"

The resulting code reads naturally, chains together cleanly, and makes it structurally impossible to call the steps in the wrong sequence.

Key Takeaways

  • Repository Pattern: separates your data-access layer from your UI logic, following the dependency inversion principle
  • Observer Pattern: enables lightweight, reactive communication between parts of your app without heavy boilerplate
  • Builder Pattern: makes constructing complex objects both readable and safe
  • All three patterns benefit substantially from TypeScript's interfaces and generics, making them noticeably cleaner than their plain JavaScript counterparts

"TypeScript isn't just about adding types — it creates a dialogue between every layer of your application." — Dan Vanderkam, author of Effective TypeScript

What to Try Next

Pick one pattern from this article and apply it to a real module in your codebase this week. Work through one vertical slice at a time, and remember that these patterns are tools, not dogma.

The strongest codebases aren't defined by how many types they contain, even in a TypeScript project. They're defined by intentionality — where every pattern is there because it's earned its place.