Tested Solutions: Working With React Design Patterns
Design patterns offer a convenient way to tackle challenges with tried-and-tested solutions, saving developers time and effort. Here’s how React design patterns allow for coherent modules with less?coupling.
React design patterns provide software engineers with two key advantages. First, they offer a convenient way of addressing software development problems with tried-and-tested solutions. And second, they greatly ease the creation of highly coherent modules with less coupling. In this article, I detail the most crucial React-specific design patterns and best practices, and examine the usefulness of general design patterns for different use cases in React.
Common React Design Patterns
Though general design patterns can be used in React, React developers have the most to gain from React-specific design patterns. Let’s examine the essentials: higher-order components, providers, compound components , and hooks.
Higher-order Components (HOC)
Through props, higher-order components (HOC) provide reusable logic to components. When we need an existing component functionality with a new UI, we use a HOC.
We combine a component with a HOC to get the desired result: a component with additional functionality as compared to the original component.
In code, we wrap a component inside a HOC, and it returns our desired component:
// A simple greeting HOC.
const Greetings = ({ name, ...otherProps }) => <div {...otherProps}>Hello {name}!</div>;
const greetWithName = (BaseComponent) => (props) => (
<BaseComponent {...props} name='Toptal Engineering Blog' />
);
const Enhanced = greetWithName(Greetings)
HOCs can contain any logic; from an architectural standpoint, they are common in Redux .
Provider Design Pattern
Using the provider design pattern, we can prevent our application from prop drilling or sending props to nested components in a tree. We can achieve this pattern with the Context API available in React:
import React, { createContext, useContext } from 'react';
export const BookContext = createContext();
export default function App() {
return (
<BookContext.Provider value="spanish-songs">
<Book />
</BookContext.Provider>
)
}
function Book() {
const bookValue = useContext(BookContext);
return <h1>{bookValue}</h1>;
}
This code example of the provider pattern demonstrates how we can directly pass props to a newly created object using context. Context includes both a provider and consumer of the state; in this example, our provider is an app component and our consumer is a book component using BookContext. Here is a visual representation:
Passing props directly from component A to component D implies that we are using the provider design pattern. Without this pattern, prop drilling occurs, with B and C acting as intermediary components.
Compound Components
Compound components are a collection of related parts that complement one another and work together. A basic example of this design pattern is a card component and its various elements.
The card component is comprised of its image, actions, and content, which jointly provide its functionality:
import React from 'react';
const Card = ({ children }) => {
return <div className="card">{children}</div>;
};
const CardImage = ({ src, alt }) => {
return <img src={src} alt={alt} className="card-image" />;
};
const CardContent = ({ children }) => {
return <div className="card-content">{children}</div>;
};
const CardActions = ({ children }) => {
return <div className="card-actions">{children}</div>;
};
const CompoundCard = () => {
return (
<Card>
<CardImage src="https://bs-uploads.toptal.io/blackfish-uploads/public-files/Design-Patterns-in-React-Internal3-e0c0c2d0c56c53c2fcc48b2a060253c3.png" alt="Random Image" />
<CardContent>
<h2>Card Title</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</CardContent>
<CardActions>
<button>Like</button>
<button>Share</button>
</CardActions>
</Card>
);
};
export default CompoundCard;
The API for compound components offers a convenient means of expressing connections between components.
Hooks
React hooks allow us to manage a component’s state and lifecycle processes. They were introduced in early 2019 , but many additional hooks became available in React version 16.8. Examples of hooks include state, effect, and custom hooks.
React’s state hook (useState) is composed of two elements, the current value and a function that updates that value when needed, depending on the state:
const [data, setData] = React.useState(initialData);
Let’s examine the state hook with a more detailed example:
import React, { useState } from "react";
export default function StateInput() {
const [input, setInput] = useState("");
const inputHandler = (e) => {
setInput(e.target.value)
}
return (
<input
onChange={inputHandler}
value={input}
placeholder="Placeholder..."
/>
);
}
We declare a state with an empty current value ("") and can update its value using the onChange handler.
Class-based components also contain effect hooks (useEffect). The useEffect hook’s functionalities are similar to those of React’s previously used lifecycle methods: componentDidMount, componentWillMount, and componentDidUpdate.
Proficient React developers have likely mastered hooks, HOCs, providers, and compound components; however, the best engineers are also equipped with general design patterns, such as proxies and singletons, and recognize when to use them in React.
An Introduction to General Design Patterns in React
General design patterns can be used with any language or framework, regardless of any potential variations in system requirements, making the entire system simpler to comprehend and maintain. Additionally, using design patterns improves the effectiveness of designer-to-designer communication: When discussing system design, software experts can refer to the name of the pattern used to solve a certain issue, allowing their peers to instantly visualize the high-level design in their minds.
There are three main categories of design patterns:
These patterns are useful in the context of React, but since they’re used in JavaScript programming in general, this knowledge is conveniently transferrable.
Creational Design Patterns in React
Creational design patterns aim to create objects applicable to various situations, allowing for more flexibility and reusability.
Builder Design Pattern
The builder design pattern simplifies object creation by providing us with steps to follow, and returning the result of the combined steps:
const BuildingHouse = ({someProps}) => {
const [constructHouse, setConstructHouse] = useState({});
const completingArchitectureWork = () => {
// Add logic to modify the state of house.
};
const completingGrayStructure = () => {
// Some logic ...
};
const completingInteriorDesign = () => {
// Add some more logic ...
};
const completingFinishingWork = () => {
// Some other logic ...
};
// Returning all updated states in one state object constructHouse.
// Passing it as props on child component.
return (
<BuildHouseLand constructHouse={constructHouse} {...someProps} />
);
}
The builder pattern separates a complex object’s production from its representation, allowing alternative representations to be made using the same construction method.
Singleton Design Pattern
The singleton design pattern is a way of defining a class such that only one object may be instantiated from it. For example, we may use a singleton to ensure that only one authentication instance is created when a user chooses from among different login methods:
Suppose we have an AuthComponent along with its singleton method authInstance that transfers the types and renders the state change depending on type. We could have an authInstance for three components that tells us whether we should render Google, Apple, or Facebook authentication components:
function AuthComponent({ authType }) {
const [currentAuth, setCurrentAuth] = useState();
const authInstance = () => {
if (authType === 'google') {
setAuth('google-authenticator')
} else if (authType === 'apple') {
setAuth('apple-authenticator')
} else if (authType === 'facebook') {
setAuth('facebook-authenticator')
} else {
// Do some extra logic.
}
}
useEffect(()=>{
authInstance()
},[authType])
return (
<div>
{currentAuth === 'google-authenticator' ? <GoogleAuth /> :
currentAuth === 'apple-authenticator' ? <AppleAuth /> :
currentAuth === 'facebook-authenticator' ? <FacebookAuth /> :
null}
</div>
)
}
function AuthInstanceUsage() {
return <AuthComponent authType='apple' />
}
A class should have a single instance and a single global entry point. Singletons should be employed only when these three conditions are fulfilled:
Lazy initialization or a delay in object initialization is a performance improvement technique in which we can wait for the creation of an object until we actually need it.
Factory Design Pattern
The factory design pattern is used when we have a superclass with multiple subclasses and need to return one of the subclasses based on input. This pattern transfers responsibility for class instantiation from the client program to the factory class.
You can streamline the process of producing objects using the factory pattern. Suppose we have a car component that can be further customized to any subcar component by altering the component’s behaviors. We see the use of both polymorphism and interfaces in the factory pattern as we have to make objects (different cars) on runtime .
In the code sample below, we can see abstract cars with props carModel, brandName, and color. The factory is named CarFactory, but it has some categories based on a brand-naming condition. The XCar (say, Toyota) brand will create its own car with specific features, but it still falls into the CarFactory abstraction. We can even define the color, trim level, and engine displacement for different car models and types within the same Car factory component.
We are already implementing inheritance as a blueprint of the class components being used. In this case, we are creating different objects by providing props to Car objects. Polymorphism also occurs, as the code determines the brand and model of each Car object at runtime, based on the types provided in different scenarios:
const CarFactoryComponent = (carModel, brandName, color) => {
<div brandName={brandName} carModel={carModel} color={color} />
}
const ToyotaCamry = () => {
<CarFactoryComponent brandName='toyota' carModel='camry' color='black'/>
}
const FordFiesta = () => {
<CarFactoryComponent brandName='ford' carModel='fiesta' color='blue'/>
}
Factory methods are typically specified by an architectural framework and then implemented by the framework’s user.
Structural Design Patterns in React
Structural design patterns can help React developers define the relationships among various components, allowing them to group components and simplify larger structures.
领英推荐
Facade Design Pattern
The facade design pattern aims to simplify interaction with multiple components by creating a single API. Concealing the underlying interactions makes code more readable. The facade pattern can also assist in grouping generic functionalities into a more specific context, and is especially useful for complex systems with patterns of interaction.
One example is a support department with multiple responsibilities, such as verifying whether or not an item was billed, a support ticket was received, or an order was placed.
Suppose we have an API that contains get, post, and delete methods:
class FacadeAPI {
constructor() { ... }
get() { ... }
post() { ... }
delete() { ... }
}
Now we’ll finish implementing this facade pattern example:
import { useState, useEffect } from 'react';
const Facade = () => {
const [data, setData] = useState([]);
useEffect(()=>{
// Get data from API.
const response = axios.get('/getData');
setData(response.data)
}, [])
// Posting data.
const addData = (newData) => {
setData([...data, newData]);
}
// Using remove/delete API.
const removeData = (dataId) => {
// ...logic here...
}
return (
<div>
<button onClick={addData}>Add data</button>
{data.map(item=>{
<>
<h2 key={item.id}>{item.id}</h2>
<button onClick={() => removeData(item.id)}>Remove data</button>
</>
})}
</div>
);
};
export default Facade;
Note one important limitation of the facade pattern: A subset of the client base requires a streamlined interface to achieve the overall functionality of a complex subsystem.
Decorator Design Pattern
The decorator design pattern uses layered, wrapper objects to add behavior to existing objects without modifying their inner workings. This way a component can be layered or wrapped by an infinite number of components; all outer components can change their behavior instantly but the base component’s behavior doesn’t change. The base component is a pure function that just returns a new component without side effects.
A HOC is an example of this pattern. (The best use case for decorator design patterns is memo, but that is not covered here as there are many good examples available online .)
Let’s explore decorator patterns in React:
export function canFly({ targetAnimal }) {
if (targetAnimal) {
targetAnimal.fly = true;
}
}
// Example 1.
@canFly()
// We can define a list of decorators here to any class or functional components.
class Eagle(){
// ...logic here...
}
// Example 2
const Eagle = () => {
@canFly()
function eagleCanFly() {
// ...logic here...
}
}
In this example, canFly is a method that can be used anywhere without any side effects. We can define decorators on top of any class component, or we can use them on functions being declared within class or functional components.
Decorators are a powerful code design pattern that allows you to write cleaner and more maintainable React components, but I still prefer HOCs over class decorators. Because decorators are still an ECMAScript proposal, they may change over time; therefore, use them with caution.
Bridge Design Pattern
The bridge design pattern is very powerful in any front-end application because it separates an abstraction from its implementation so the two can change independently.
We use bridge design patterns when we want binding runtime implementations, have a proliferation of classes as a result of a coupled interface and numerous implementations, want to share an implementation among multiple objects, or when we need to map orthogonal class hierarchies.
Let’s observe how the bridge pattern works with these TV and controller example:
Suppose each TV and remote are a different brand. Each remote would be referenced to its proprietary brand. A Samsung TV would have to be referenced to a Samsung remote; a Sony remote would not work with it because even though it contains similar buttons (e.g., on, off, channel up, and channel down), its implementation is different.
// Just a path to remotes.
import { remote1, remote2, remote3 } from "./generic-abstraction";
// Just a path to TVs.
import { TV1, TV2, TV3 } from "./implementation-of-abstraction";
// This function is a bridge of all these remotes and TVs.
const BridgeTV = () => {
// Some states calculate the type of remote so that we can return TV types.
return (
<TVGraphicsChanger
{...someBridgeProps}
// Some hidden logic to abstract the remote types and return a TV.
uiComponent={
remote1 ? <TV1 /> : remote2 ? <TV2 /> : remote3 ? <TV3 /> : null
}
/>
);
};
In the bridge design pattern, we have to remember that the reference should be correct and reflect the correct change.
Proxy Design Pattern
The proxy design pattern uses a proxy that acts as a surrogate or placeholder when accessing an object. An everyday example of a proxy is a credit card that represents physical cash or money in a bank account.
Let’s see this pattern in action and code a similar example in which we transfer funds and a payment application checks the available balance in our bank account:
const thirdPartyAPI = (accountId) => { ... }
// The proxy function.
const checkBalance = accountId => {
return new Promise(resolve => {
// Some conditions.
thirdPartyAPI(accountId).then((data) => { ... });
});
}
// Test run on proxy function.
transferFunds().then(someAccountId => {
// Using proxy before transferring or money/funds.
if(checkBalance(someAccountId)) { ... }
}).catch(error=> console.log('Payment failed', error))
In our code, the payment app’s verification of the account’s balance serves as the proxy.
Behavioral Design Patterns in React
Behavioral design patterns focus on communication among various components, making them well-suited for React due to its component-centric nature.
State Design Pattern
The state design pattern is commonly used to add basic units of encapsulation (states) in component programming. An example of the state pattern is a TV with its behavior being changed through a remote:
Based on the state of the remote button (on or off), the state of the TV is changed accordingly. Similarly, in React, we can change the state of a component based on its props or other conditions.
When an object’s state changes, its behavior is modified:
// Without state property.
<WithoutState otherProps={...otherProps} state={null}/>
// With state property.
<WithState otherProps={...otherProps} state={...state} />
The WithState component acts differently in these code examples, depending on when we provide a state prop and when we provide null to it. So our component changes its state or behavior according to our input, which is why we call the state design pattern a behavioral pattern.
Command Design Pattern
The command design pattern is an excellent pattern for designing clean, decoupled systems. This pattern allows us to execute a piece of business logic at some point in the future. I particularly want to focus on the command pattern because I believe it is the root pattern of Redux . Let’s see how the command pattern can be used with a Redux reducer:
const initialState = {
filter: 'SHOW_ALL',
arr: []
}
function commandReducer(state = initialState, action) {
switch (action.type) {
case 'SET_FILTER': { ... }
case 'ADD_TODO': { ... }
case 'EDIT_TODO': { ... }
default:
return state
}
}
In this example, the Redux reducer includes multiple cases—triggered by different situations—that return different behaviors.
Observer Design Pattern
The observer design pattern allows objects to subscribe to changes in the state of another object and automatically receive notifications when the state changes. This pattern decouples the observing objects from the observed object, thus promoting modularity and flexibility.
In the Model-View-Controller (MVC) architecture , the observer pattern is commonly used to propagate changes from the model to the views, enabling the views to observe and display the updated state of the model without requiring direct access to the model’s internal data:
const Observer = () => {
useEffect(() => {
const someEventFunc = () => { ... }
// Add event listener.
documentListener('EVENT_TRIGGER_NAME', () => { ... })
return () => {
// Remove event listener.
documentListener('EVENT_TRIGGER_NAME', () => { ... })
}
}, [])
}
The observer object distributes communication by introducing “observer” and “subject” objects, whereas other patterns like the mediator and its object encapsulate communication between other objects. Creating reusable observables is easier than creating reusable mediators, but a mediator can use an observer to dynamically register colleagues and communicate with them.
Strategy Design Pattern
The strategy design pattern is a way to change some behavior dynamically from the outside without changing the base component. It defines an algorithm family, encapsulates each one, and makes them interchangeable. The strategy allows the parent component to change independently of the child that uses it. You can put the abstraction in an interface and bury the implementation details in derived classes:
const Strategy = ({ children }) => {
return <div>{children}</div>;
};
const ChildComp = () => {
return <div>ChildComp</div>;
};
<Strategy children={<ChildComp />} />;
As the open-closed principle is the dominant strategy of object-oriented design, the strategy design pattern is one way to conform to OOP principles and still achieve runtime flexibility.
Memento Design Pattern
The memento design pattern captures and externalizes an object’s internal state so that it can subsequently be restored without breaking encapsulation. We have the following roles in the memento design pattern:
Let’s learn it by examining a code example. The memento pattern uses the chrome.storage API (I’ve removed its implementation details) to store and load the data. In the following conceptual example, we set data in setState function and load data in getState function:
class Memento {
// Stores the data.
setState(){ ... }
// Loads the data.
getState() { ... }
}
But the actual use case in React is as follows:
const handler = () => ({
organizer: () => {
return getState(); // Organizer.
},
careTaker: (circumstance, type) => {
return type === "B" && circumstance === "CIRCUMSTANCE_A"
? {
condition: "CIRCUMSTANCE_A",
state: getState().B,
}
: {
condition: "CIRCUMSTANCE_B",
state: getState().B,
};
//
},
memory: (param) => {
const state = {};
// Logic to update state based on param.
// Send param as well to memorize the state based on.
// Circumstances for careTaker function.
setState({ param, ...state }); // Memories.
},
});
In this abstract example, we return the getState in the organizer (in handler), and a subset of its state in the two logical branches within the return statement of careTaker.
Why React Patterns Matter
Though patterns offer tried-and-tested solutions to recurring problems, software engineers should be aware of the benefits and drawbacks of any design pattern before applying it.
Engineers routinely use React’s state, hooks, custom hooks, and Context API design patterns, but understanding and employing the React design patterns I covered will strengthen a React developer’s foundational technical skills and serve many languages. Through these general patterns, React engineers are empowered to describe how code behaves architecturally rather than just using a specific pattern to meet requirements or address a single issue.