Design Smarter, Not Harder: Must-Know Design Patterns for Frontend Development

Share This Post

“A Pattern Language: Towns, Buildings, Construction” by Christopher Alexander (1977) is a groundbreaking book in the field of architecture. It introduced the concept of design patterns, which later influenced software development. The “Gang of Four” (Erich Gamma, John Vlissides, Ralph Johnson, and Richard Helm) expanded upon this idea in 1994 in their book Design Patterns: Elements of Reusable Object-Oriented Software. Their work presented 23 design patterns in frontend development to address common challenges in object-oriented programming.

Since the book, many other object-oriented patterns have been discovered. However, the book is considered a cornerstone in software engineering because it established a common vocabulary to describe the solutions and provided a structured and reusable approach.

In this article, we will share some of the design patterns used to transform a legacy codebase into a Domain-Driven Design (DDD) project. Instead of just covering theoretical concepts, we will dive into practical, real-world examples of how these design patterns for frontend development can be applied.

🔎 Need more details on design patterns? Check out refactoring.guru for excellent explanations.

Must-Know Design Patterns for Frontend Development

Singleton: Single Source of Truth

The Singleton Pattern is a creational design pattern useful when you need a single source of truth in your frontend applications. It can also be used to manage global states, routing services, and app configurations. It guarantees one instance across the app, preventing re-instantiations and keeping the instance private and secure. In the example below, we implement a singleton for a DevToolsService that acts as a bridge to integrate with Redux DevTools for easier debugging in the Chrome console.

Using the Singleton Pattern here ensures that the DevTools connection is established once and shared across the app, preventing multiple redundant connections.

How it works:

  1. #instance is a private static property that holds the single instance.
  2. getInstance() is a static method that acts like a global access point.
  3. On the first call, getInstance() creates the instance and stores it.
  4. On all future calls, getInstance() simply returns the already created instance — guaranteeing that the same object is reused everywhere.
  5. Finally, export default ensures that whenever another module imports this service, it always gets the same pre-initialized instance.
class DevToolsService {
  	static #instance: DevToolsService | null = null;


  	static getInstance(): DevToolsService {
    		if (!DevToolsService.#instance) {
      			DevToolsService.#instance = new DevToolsService();
    		}
   	 	return DevToolsService.#instance;
  	}
}


export default DevToolsService.getInstance();

Factory Method (Virtual Constructor): Creating Components With Ease

Factory Method is a creational pattern that allows the creation of objects to be delegated to subclasses (or a factory class), instead of directly instantiating them in the main application flow.

  Key Vault Secrets: Integration with Azure Arc

For example, In a pizza ordering system, each store type might have slightly different order statuses (chips):

  • Corporate stores might have corporate discounts applied.
  • Delivery-only stores might have to await quality checks.

However, many statuses are common to all stores, such as:

  • Order received
  • Preparing
  • Ready
  • Delivered

In this example, we apply the Factory Method Pattern to create different PizzaChip styles depending on the type of store (corporate vs delivery-only). The abstract class (PizzaChip) defines the common logic for all stores, while the Factory (PizzaChipFactory) decides which concrete class to instantiate. Each concrete class (like CorporatePizzaChip) defines only the custom part of the chip styles, keeping each store’s logic isolated and protected. 

export type ChipLabels =
 | 'order_received'
 | 'preparing'
 | 'ready'
 | 'delivered'
 | 'awaiting_quality_check'
 | 'corporate_discount_applied'
 | 'out_for_delivery';


export abstract class PizzaChip {
 private common(): { label: ChipLabels; color: string }[] {
   return [
     { label: 'order_received', color: '#FF7700' },
     { label: 'preparing', color: '#FF7700' },
     { label: 'ready', color: '#FF7700' },
     { label: 'delivered', color: '#FF7700' },
   ];
 }
 protected abstract custom(): { label: ChipLabels; color: string }[];


 getList(): { label: ChipLabels; color: string }[] {
   return this.custom().concat(this.common());
 }
}


export class PizzaChipFactory {
 static create(storeType: string): PizzaChip {
   switch (storeType) {
     case 'corporate':
       return new CorporatePizzaChip();
     default:
       return new DeliveryOnlyPizzaChip();
   }
 }
}


class CorporatePizzaChip extends PizzaChip {
 custom(): { label: ChipLabels; color: string }[] {
   return [
     { label: 'corporate_discount_applied', color: '#FF0000' },
     { label: 'out_for_delivery', color: '#FF0000' },
   ];
 }
}


class DeliveryOnlyPizzaChip extends PizzaChip {
 custom(): { label: ChipLabels; color: string }[] {
   return [{ label: 'awaiting_quality_check', color: '#FF0000' }];
 }
}

By using a Factory Method, we ensure that the correct chip is created automatically, without spreading store-specific conditions throughout the codebase. This supports the Single Responsibility Principle and makes adding new store types easy — just add a new class and one new line in the factory.

This design is especially useful in a Domain-Driven Frontend Architecture, where each store type might represent its own Bounded Context, with slightly different rules and processes. By isolating these rules into separate classes and protecting them via the factory, we enforce clear Boundaries between contexts. This approach results in cleaner, more maintainable, and future-proof code.


Abstract Factory: Creating Related Components With Consistency

Let’s say that for each store type (corporate vs delivery-only), you not only have different PizzaChip, but also different:

  • OrderSummary components (showing different key metrics like average delivery time or corporate discounts applied).
  • Notifications (different wording in the order status updates sent to customers).

In that case, you could define an Abstract Factory like this:

interface PizzaChip {
 getList(): { label: string; color: string }[]; 
}
interface StoreNotification {
 getWelcomeMessage(): string; 
}
// The factory interface defines what each type of store factory must be able to create:
interface PizzaStoreFactory {
 createChipLegend(): PizzaChip; 
 createNotification(): StoreNotification; 
}


class CorporateStoreFactory implements PizzaStoreFactory {
 createChipLegend(): PizzaChip {
   return new CorporatePizzaChip();
 }


 createNotification(): StoreNotification {
   return new CorporateNotification();
 }
}
class DeliveryOnlyStoreFactory implements PizzaStoreFactory {
 createChipLegend(): PizzaChip {
   return new DeliveryOnlyPizzaChip();
 }
 createNotification(): StoreNotification {
   return new DeliveryOnlyNotification();
 }
}
//These are the actual implementations of PizzaChip and StoreNotification for each store type:
class CorporatePizzaChip implements PizzaChip {
 getList() {
   return [
     { label: 'order_received', color: '#FF7700' },
     { label: 'corporate_discount_applied', color: '#FF0000' },
   ];
 }
}
class DeliveryOnlyPizzaChip implements PizzaChip {
 getList() {
   return [
     { label: 'order_received', color: '#FF7700' },
     { label: 'awaiting_quality_check', color: '#FF0000' },
   ];
 }
}
class CorporateNotification implements StoreNotification {
 getWelcomeMessage() {
   return 'Welcome to our corporate pizza portal!';
 }
}
class DeliveryOnlyNotification implements StoreNotification {
 getWelcomeMessage() {
   return 'Welcome to our delivery-only pizza tracker!';
 }
}
function createUI(factory: PizzaStoreFactory) {
 const legend = factory.createChipLegend().getList();
 const notification = factory.createNotification().getWelcomeMessage();
 console.log({ legend, notification });
}
// Create UI for corporate store
createUI(new CorporateStoreFactory());
// Create UI for delivery-only store
createUI(new DeliveryOnlyStoreFactory());

Adapter: Bridging Incompatible APIs

The Adapter Pattern is a structural design pattern that allows two objects with incompatible interfaces to work together. It is used when we integrate legacy systems with new applications to reuse existing code without modification. In this case, we can use the Adapter Pattern to create a class between the infrastructure layer and our API call. We can adapt what we are expecting from the API and return the adapted data.

class OldService {
 getData() {
   return {
     dataPoints: [
       { month: 'January', amount: 100 },
       { month: 'February', amount: 150 },
     ],
   };
 }
}


interface ChartData {
 label: string;
 value: number;
}


class ServiceAdapter {
 constructor(private legacyService: OldService) {}


 getChartData(): ChartData[] {
   const legacyData = this.legacyService.getData();
   return legacyData.dataPoints.map(point => ({
     label: point.month,
     value: point.amount,
   }));
 }
}


const legacyService = new OldService();
const adapter = new ServiceAdapter(legacyService);
console.log(adapter.getChartData());

Criteria: Filtering Data Like a Pro

The Criteria Pattern is a design pattern used to filter a set of objects using different criteria and combine them logically. It provides a flexible way to perform complex filtering without cluttering your code with multiple conditional statements.

  How teams increase agility with agile retrospectives

In frontend development, it’s particularly useful when you need to handle dynamic searches, filters, or queries—especially when dealing with forms, search bars, or paginated data.

export class EmailCriteria {
 constructor(
   public sender: string = '',
   public subject: string = '',
   public date: string = ''
 ) {}


 static fromFilters(filters: Partial<EmailCriteria>): EmailCriteria {
   return new EmailCriteria(
     filters.sender ?? '',
     filters.subject ?? '',
     filters.date ?? ''
   );
 }
 }
}


export class EmailService {
 constructor(
   private repository: EmailRepository = new EmailHttpRepository(),
 ) {}


 getEmail(criteria: EmailCriteria): Promise<PaginationResponse<Email>> {
   return this.repository.getEmail(criteria);
 }
}

Builder: Constructing Complex UI Step-By-Step

The Builder Pattern is a creational design pattern used to construct objects with many optional parameters step-by-step. It separates the construction process from the final representation, allowing you to use the same construction logic to create different configurations or versions of an object.

In our domain, we have an entity called Pizza that can be created with various options, such as Margherita, Four Cheese, or Pepperoni. Since each pizza can have different ingredients and configurations, we need a flexible way to construct them without writing repetitive code for each variation.

This is where the Builder Pattern comes in. We define a PizzaBuilder interface that provides methods to add different components step-by-step—such as sauce, cheese, and toppings. Each method allows us to set specific ingredients, making the construction process clear and adaptable.

abstract class PizzaBuilder {
 abstract addSauce(): void;
 abstract addCheese(): void;
 abstract addToppings(toppings: string[]): void;
}


class Pizza {
 ingredients: string[] = [];


 showIngredients() {
   console.log(`Pizza ingredients: ${this.ingredients.join(', ')}`);
 }
}


class MargheritaPizzaBuilder implements PizzaBuilder {
 private pizza: Pizza;


 constructor() {
   this.pizza = new Pizza();
 }


 addSauce() {
   this.pizza.ingredients.push('Tomato Sauce');
 }


 addCheese() {
   this.pizza.ingredients.push('Mozzarella Cheese');
 }


 addToppings(toppings: string[]) {
   this.pizza.ingredients.push(...toppings);
 }


 getPizza(): Pizza {
   const result = this.pizza;
   this.pizza = new Pizza(); // Reset for next pizza
   return result;
 }
}


class PizzaDirector {
 constructor(private builder: PizzaBuilder) {}


 buildSimpleMargherita() {
   this.builder.addSauce();
   this.builder.addCheese();
 }


 buildMargheritaWithExtraToppings() {
   this.builder.addSauce();
   this.builder.addCheese();
   this.builder.addToppings(['Basil', 'Olives']);
 }
}


const builder = new MargheritaPizzaBuilder();
const director = new PizzaDirector(builder);


console.log('🍕 Simple Margherita Pizza:');
director.buildSimpleMargherita();
builder.getPizza().showIngredients();


console.log('\n🍕 Margherita with extra toppings:');
director.buildMargheritaWithExtraToppings();
builder.getPizza().showIngredients();


console.log('\n🍕 Custom Pizza (without director):');
builder.addToppings(['Pepperoni', 'Mushrooms']);
builder.getPizza().showIngredients();

Key Advantages of Using Design Patterns for Frontend Development

Improved Code Maintainability
Design patterns for frontend development offer clear structure and separation of concerns, making code easier to extend, refactor, and debug over time.

  Dedicated Software Teams vs. Extended Teams in Software Development

Scalability & Flexibility
Patterns like Factory Method and Abstract Factory allow you to add new features (or product variations) with minimal changes to the existing codebase — a critical need in fast-evolving frontend projects.

Consistency Across Teams
When teams follow well-known patterns, collaboration becomes easier, onboarding new developers is faster, and the overall quality of the codebase improves.

Better Testability
Design patterns for frontend development encourage modular design, which simplifies unit testing.

Encapsulation of Complex Logic
With patterns like Singleton or Factory, complex initialization, configuration, or conditional logic can be hidden inside the pattern itself — leaving the rest of your app clean and focused on business logic.

Conclusion

As modern frontend applications grow in complexity, applying well-established design patterns for frontend development becomes essential for writing scalable, maintainable, and understandable code. Whether it’s managing global services with the Singleton Pattern, creating flexible UI elements with the Factory Method, ensuring consistent object families with the Abstract Factory,  filtering data efficiently with the Criteria Pattern, transforming interfaces with the Adapter Pattern, or building complex objects step-by-step with the Builder Pattern, design patterns provide proven solutions to architectural challenges.

Design patterns are not just theoretical concepts for backend developers — they also bring tangible benefits to frontend development, especially in large-scale applications. By embracing these design patterns for frontend development, teams can future-proof their codebases, make their architectures more predictable, and ultimately deliver more reliable and maintainable user experiences.

Author

  • Beste Burcu Bayhan

    Graduated from Istanbul Technical University with a bachelor degree of computer engineering in June 2018. During her studies, she has been involved in projects in various areas including web development, computer vision and computer networks.

    View all posts

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Subscribe To Our Newsletter

Get updates from our latest tech findings

About Apiumhub

Apiumhub brings together a community of software developers & architects to help you transform your idea into a powerful and scalable product. Our Tech Hub specialises in Software ArchitectureWeb Development & Mobile App Development. Here we share with you industry tips & best practices, based on our experience.

Estimate Your Project

Request
Popular posts​
Get our Book: Software Architecture Metrics

Have a challenging project?

We Can Work On It Together

apiumhub software development projects barcelona
Secured By miniOrange