DEV Community

Cover image for Design Patterns in Front-end: Building Smarter, Scalable Interfaces
Mía Salazar
Mía Salazar

Posted on

Design Patterns in Front-end: Building Smarter, Scalable Interfaces

As front-end applications grow in complexity, so does the need for structure, clarity, and scalability. That’s where design patterns come in. Not as rigid rules, but as reusable solutions to common problems in software design.

Design patterns help front-end developers write more maintainable, testable, and elegant code. In this article, we’ll explore how some classic and modern patterns apply to front-end development, especially in frameworks like React, Vue, Angular, or even vanilla JavaScript.

What Are Design Patterns?

Design patterns are generalized solutions to recurring problems. They're not specific code, but templates you can adapt depending on your context.

In front-end development, these patterns help us:

  • Manage UI complexity
  • Separate concerns (e.g., logic vs presentation)
  • Improve reusability and testability
  • Communicate intent clearly with other developers

Next we will see some patterns with some code examples.

Compound Pattern

The Compound Pattern is a design approach where multiple components work together to share an implicit state. It allows a parent component to manage state while its child components access and manipulate it via context or props, creating a more flexible and declarative API. This pattern is ideal for building custom, reusable UI elements like tabs or accordions.

const List = ({ children }) => (
  <ul>
    {children}
  </ul>
);

const Item = ({ text }) => {

  return (
    <li>
      {text}
    </li>
  );
};
Enter fullscreen mode Exit fullscreen mode

Factory Pattern

The factory pattern is useful when you need to dynamically generate components or DOM elements based on certain input.

Example

const componentFactory = (type) => {
  switch (type) {
    case 'text':
      return <input type="text" />;
    case 'checkbox':
      return <input type="checkbox" />;
    case 'button':
      return <button>Click Me</button>;
    default:
      return null;
  }
};

const FormField = ({ fieldType }) => componentFactory(fieldType);

Enter fullscreen mode Exit fullscreen mode

Higher-Order Components (HOC)

This is a classic Decorator Pattern. You enhance a component by wrapping it in another. This pattern allow you to share logic between component, keep your components focused and avoid duplicating functionality.

Example

const withLoading = (Component) => {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <p>Loading...</p>;
    return <Component {...props} />;
  };
};

const DataList = ({ data }) => <ul>{data.map(i => <li key={i}>{i}</li>)}</ul>;
const DataListWithLoading = withLoading(DataList);

Enter fullscreen mode Exit fullscreen mode

Observer Pattern

This is the foundation of tools like Redux. In front-end apps, this pattern is essential for managing state changes and reactive updates across components.

The Observer Pattern is a design pattern where an object (called the subject) maintains a list of observers (listeners, subscribers) and notifies them automatically of any state changes.

Example

const listeners = [];

function subscribe(fn) {
  listeners.push(fn);
}

function notify(data) {
  listeners.forEach(fn => fn(data));
}

notify('Data updated');
Enter fullscreen mode Exit fullscreen mode

Flux Pattern

While the Observer pattern underpins many reactive systems, Flux takes it further by enforcing a unidirectional data flow, which simplifies reasoning about state in large front-end applications.

Flux was popularized by Facebook to manage the complexity of state changes in React apps. Unlike the Observer pattern, where multiple objects may listen and update independently, Flux organizes updates in a strict cycle: Action → Dispatcher → Store → View.

// Dispatcher
function dispatch(action) {
  store.receive(action);
}

// Store
const store = {
  state: [],
  listeners: [],
  receive(action) {
    if (action.type === 'ADD_TODO') {
      this.state.push(action.payload);
      this.notify();
    }
  },
  subscribe(fn) {
    this.listeners.push(fn);
  },
  notify() {
    this.listeners.forEach(fn => fn(this.state));
  }
};

// Usage
store.subscribe(data => console.log('Updated:', data));
dispatch({ type: 'ADD_TODO', payload: 'Buy milk' });

Enter fullscreen mode Exit fullscreen mode

Container-Presentational Pattern

One of my favourite patterns. It's useful because it allows you to separate your logic "containers" from only "presentational" components.

Example

//user.container.jsx
const User = () => {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchUser().then(setUser);
  }, []);
  return <UserViewuser={user} />;
};

//user.view.jsx
const UserView = ({ user }) => (
  <h1>{user ? user.name : "Loading..."}</h1>
);
Enter fullscreen mode Exit fullscreen mode

Strategy Pattern

Defines a family of interchangeable algorithms, allowing behavior to be selected at runtime. Instead of writing conditional logic that changes behavior depending on the algorithm used, you delegate the behavior to a strategy object that implements the required functionality.

Example

const required = (value) => value ? null : 'Required';
const email = (value) => /\S+@\S+\.\S+/.test(value) ? null : 'Invalid email';

const validate = (value, strategies) => {
  for (let fn of strategies) {
    const error = fn(value);
    if (error) return error;
  }
  return null;
};

const error = validate('leia@organa.com', [required, email]);
Enter fullscreen mode Exit fullscreen mode

Proxy Pattern

A wrapper object that controls access to another object. This pattern is useful for caching, lazy loading, or logging.

Example

const fetchUser = (function () {
  const cache = {};
  return async (id) => {
    if (cache[id]) return cache[id];
    const res = await fetch(`/user/${id}`);
    const data = await res.json();
    cache[id] = data;
    return data;
  };
})();

Enter fullscreen mode Exit fullscreen mode

Adapter Pattern

One of my favourites. Translates one interface into another. Useful when integrating APIs or libraries or during a migration.

Example

const apiUser = { user_name: 'leia', user_email: 'leia@organa.com' };

// Adapter
const adaptUser = (user) => ({
  name: user.user_name,
  email: user.user_email,
});

const user = adaptUser(apiUser);

Enter fullscreen mode Exit fullscreen mode

Lazy Loading Pattern

The Lazy Loading pattern delays the loading of resources until they are actually needed, improving performance. This pattern can be applied to load components on demand. This is commonly used in React with Suspense.

Lazy Loading Pattern is benefitial because it improves performance by loading resources only when needed and it reduces the initial load time, especially in large applications.

Example

import React, { Suspense, lazy } from "react";

const LazyComponent = lazy(() => import("./LazyComponent"));

const App = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Best Practices When Using Design Patterns

  • Don’t force it: Patterns are tools, not requirements. Use them when they solve a real problem.
  • Name things clearly: If you use a pattern, make its intention obvious to future readers.
  • Refactor when needed: Patterns often emerge naturally as your app grows.

Conclusion

Design patterns are not about writing more code, they’re about writing better code. In front-end development, they offer a way to manage complexity, improve communication between team members, and build more scalable interfaces.

Patterns give structure to chaos. Use them wisely, and your front-end code will thank you.

Top comments (1)

Collapse
 
manuartero profile image
Manuel Artero Anguita 🟨 • Edited

Hi Mía, good article! love this sentence > Don’t force it: Patterns are tools, not requirements

(minor tip) append the lang to the block codes (``ts) like

Image description

for syntax highlighting💪