This article is published in English.
React Design Patterns: From Classic OOP to Modern Hooks
Explains how classic software patterns like Singleton, Factory, and Observer apply in React, alongside React-specific patterns like HOCs, Hooks, and Compound Components.
Many developers who are new to React don't immediately realize that design patterns apply outside of backend systems. It's common to assume that these concepts are only relevant to server-side architecture, only to later discover, while building frontend applications professionally, that many of these patterns are already being applied instinctively, without a conscious label attached to them.
Design patterns are essentially proven, reusable templates for tackling recurring problems that show up again and again in software projects. When you need your codebase to stay organized, well-structured and logically connected, these patterns give you a blueprint to follow. They function as codified best practices that raise the quality of your code and extend how long it stays maintainable.
Among the biggest advantages design patterns bring are reusability, maintainability, scalability, and gains in speed and efficiency. Before diving into patterns specific to React, it's worth reviewing the foundational software engineering patterns that predate frontend frameworks entirely.
Classic Software Engineering Patterns
These are patterns that exist independently of any particular language, applying broadly across object-oriented and functional paradigms. React itself relies on several of these internally, and engineers working within the React codebase use them to organize state, coordinate lifecycle dependencies between components, trim down bundle size, and keep intricate UI logic understandable.
Singleton Pattern
This pattern guarantees that a class or object has exactly one instance for the entire runtime of an application, while also exposing a single global access point to that instance.
On the frontend, the Singleton pattern is handy for managing shared resources — things like centralized state stores, app-wide configuration objects, analytics tracking instances, or a single shared API client.
// Singleton API Service
class APIClient {
constructor() {
if (APIClient.instance) {
return APIClient.instance;
}
this.baseURL = "https://api.example.com";
APIClient.instance = this;
}
fetchData(endpoint) {
return fetch(`${this.baseURL}${endpoint}`).then(res => res.json());
}
}
// Any module importing/instantiating this gets the exact same instance
const client1 = new APIClient();
const client2 = new APIClient();
console.log(client1 === client2); // true
With modern ES modules, you don't need manual instance-checking logic anymore — simply exporting one shared object or instance is enough to get singleton behavior for free.
// apiClient.js
export const apiClient = new APIClient(); // ES modules cache exports automatically
Factory Pattern
The Factory pattern defines an interface for creating objects, without the calling code needing to know the specific class or constructor function responsible for producing that object.
This pattern shines when you need to generate UI elements dynamically, deal with API responses that come back in several different shapes, or build abstractions that hide platform differences, such as unifying how web and mobile handle input events.
// Button Factory for dynamically rendering UI elements
function createButton(type, config) {
switch (type) {
case 'primary':
return { role: 'btn-primary', label: config.label, onClick: config.onClick };
case 'icon':
return { role: 'btn-icon', icon: config.iconName, onClick: config.onClick };
case 'link':
return { role: 'btn-link', href: config.url };
default:
throw new Error(`Unsupported button type: ${type}`);
}
}
const primaryBtn = createButton('primary', { label: 'Submit', onClick: () => {} });
Observer Pattern
This is the underlying mechanism behind event listeners and behind state management libraries such as Redux, Zustand, and MobX. It's worth noting that React's Context API does not rely on this pattern.
class EventEmitter {
constructor() {
this.events = {};
}
// Subscribe
on(event, listener) {
if (!this.events[event]) this.events[event] = [];
this.events[event].push(listener);
}
// Publish
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(listener => listener(data));
}
}
}
// Usage
const store = new EventEmitter();
// Component A subscribes to state changes
store.on('userLoggedIn', user => console.log(`Welcome, ${user.name}!`));
// Login Service triggers event
store.emit('userLoggedIn', { name: 'Sarah' });
Module Pattern
This pattern wraps code in a closure so that internal variables and functions stay private, exposing only a deliberately chosen public interface.
Before native ES6 modules were available, this was the standard technique for keeping the global window object clean and for achieving genuine variable privacy in JavaScript.
// Module using IIFE (Immediately Invoked Function Expression)
const ShoppingCartModule = (function () {
// Private variable
const cart = [];
// Private function
function calculateTotal() {
return cart.reduce((sum, item) => sum + item.price, 0);
}
// Public API
return {
addItem(item) {
cart.push(item);
},
getTotal() {
return calculateTotal();
}
};
})();
ShoppingCartModule.addItem({ name: 'Keyboard', price: 100 });
console.log(ShoppingCartModule.getTotal()); // 100
console.log(ShoppingCartModule.cart); // undefined (Private!)
These days native ES modules with import and export handle scoping automatically, so you rarely need to hand-roll a closure just to keep variables private.
// cart.js
const cart = []; // Private to cart.js file
export const addItem = (item) => cart.push(item);
export const getTotal = () => cart.reduce((sum, i) => sum + i.price, 0);
React Specific Component Design Patterns
The patterns below tackle challenges unique to rendering UI, sharing logic between components, managing state across a tree, and avoiding excessive prop drilling.
This section walks through the following component-level patterns:
- the HOC pattern
- the pattern built around hooks
- the compound component pattern
- the container versus presentational split
- the render props approach
- the emerging AI UI pattern
HOC Pattern
The Higher Order Component, or HOC, is one of the earliest techniques React offered for handling cross-cutting concerns. Consider a scenario where, once a user opts into tracking, you need to fire an analytics event as soon as a component mounts. Hardcoding that logic into every page component quickly becomes repetitive, and updating it later—say, when the analytics SDK changes—turns into a maintenance headache. The HOC pattern exists to solve exactly this kind of problem.
A HOC takes an existing component and layers additional behavior on top of it, without the original component needing any awareness of that extra behavior. That separation is the entire point of the pattern. For instance, a Page component stays focused purely on rendering, while wrapping it as withAnalytics(Page) takes care of the tracking logic separately.
In newer codebases, hooks have largely taken over this responsibility, but you'll still encounter the HOC pattern frequently in legacy React projects.
Hooks Pattern
Few additions have changed React as fundamentally as hooks. Introduced in React 16.8 in 2019, they've since become the default, go-to approach for writing the majority of modern React code.
The hooks pattern lets developers pull out and reuse stateful logic and side effects across components using ordinary functions, effectively replacing older approaches such as HOCs and render props.
Compound Pattern
The compound pattern enables a set of components to collaborate on shared state and logic implicitly, without needing to pass everything down manually through props. It shows up most often in complex interactive UI pieces like dropdown menus, accordions, tabs, and navigation menus.
Container / Presentational Pattern
This pattern enforces a clean separation of concerns by dividing a component's responsibilities into two distinct roles: one that manages application logic, and one that's purely concerned with rendering the UI.
The container component is responsible for deciding what data the user should see. It owns the state, handles side effects, and contains the application logic.
The presentational component, by contrast, is concerned with how that data gets displayed. It simply receives data and callback functions through props, without ever mutating the underlying data itself.
Modern React development leans heavily toward custom hooks rather than this container/presentational split. Rather than building a dedicated container component just to fetch data, you can extract that fetching logic into a custom hook and call it directly inside whichever component needs it. This keeps the separation of concerns intact while cutting out extra layers of component nesting and repetitive boilerplate.
Render Props Pattern
With this pattern, a function gets passed down as a prop to a component, giving that component control over state and logic while leaving the decision of what to render up to whoever consumes it.
The essential idea behind render props is that instead of the wrapper component rendering a hardcoded UI itself, it runs its internal logic and then invokes a function prop to generate the resulting JSX.
In contemporary React, custom hooks have mostly taken over the role render props used to play for sharing pure data logic. That said, render props still make sense when a component needs to own and control an entire subtree while still letting the caller decide on the markup. Headless UI libraries such as React Aria and TanStack Table rely on this approach to ship complicated behavior—accessibility handling, focus management, and similar concerns—without dictating any particular styling or DOM structure.
AI UI Pattern
This is a comparatively recent addition to the list. Building AI-driven interfaces, whether chatbots or more general intelligent assistants, demands careful coordination between backend AI services and the reactive UI layer. The AI UI pattern is essentially about wiring large language model backends to responsive client interfaces so they can handle conversational exchanges, streamed responses, and asynchronous model execution smoothly.
One of the central ideas here is keeping the backend and the proxy layer separate from the client. To avoid exposing API keys and to manage the computational load properly, all AI-related calls need to pass through a server-side layer—something like Next.js Route Handlers or a Node.js API proxy sitting in front of Vite. Calling AI services directly from the browser is something you should avoid entirely.