In this post I try to identify some of the Gang of Four (GOF) design patterns used, or can be used, in React JS applications. Most patterns I list down are not strictly React's, but belong to JavaScript, so they're equally applicable in other frameworks too, such as Vue and Angular.
Many of the React patterns won’t exactly fit the definition of the GOF design patterns, because JavaScript – which is the building block of React – is a dynamic language without the built-in interface, whereas GOF explains the patterns for static languages with class and interface as inherent parts of the language, such as Java.
Let’s start!
Singleton
Whenever you create an object using object literal, it’s essentially a singleton instance. We do that often in React JS applications, by exporting a centrally located piece of code, such as config or helper files:
export const CONFIG = {
SERVER_URL: process.env.REACT_APP_SERVER_URL,
GOOGLE_API_KEY: process.env.REACT_APP_GOOGLE_API_KEY,
// ...
};
export const notificationHelper = {
alertInfo(message, options){
// ...
},
alertSuccess(message, options){
// ...
},
alertWarning(message, options){
// ...
},
alertError(message, options){
// ...
},
};
Another way of course is to create a function (or ES6 class), keep the reference of its first instance created, and always return it instead of new the instance. That way, no more than one instance is ever produced.
OR
When you export
or module.exports
something, the same instance is shared across all imports
or requires
within the application, not multiple copies. So export
ing new
instance of a class would give us a singleton instance. Here’s a real-world use case of the singleton pattern in React JS application. real-world example of singleton pattern in React JS application.
Observer Pattern
You must’ve used eventing-bus
and saga watchers. These can be said to follow observer pattern, because they subscribe to an event and fire the method we provide them.
Below, we subscribe to three redux actions and set our component’s loading state based on them:
EventBus
import React, { Component } from "react";
import EventBus from "eventing-bus";
// ...
class SomeComponent extends Component {
componentDidMount() {
this.subscribeEvents();
}
componentWillUnmount() {
this.unsubscribeEvents();
}
subscribeEvents = () => {
this.getPostsRequestSubscription = EventBus.on(
PostTypes.GET_POSTS_REQUEST,
() => this.setState({ loading: true })
);
this.getPostsSuccessSubscription = EventBus.on(
PostTypes.GET_POSTS_SUCCESS,
() => this.setState({ loading: false })
);
this.getPostsFailureSubscription = EventBus.on(
PostTypes.GET_POSTS_FAILURE,
() => this.setState({ loading: true })
);
}
unsubscribeEvents = () => {
this.getPostsRequestSubscription();
this.getPostsSuccessSubscription();
this.getPostsFailureSubscription();
}
// ...
}
Here, saga watches for GET_POSTS_REQUEST
and invokes requestGetPosts
whenever it receives it.
Saga
// ...
export function watchPostRequests() {
yield takeEvery(PostTypes.GET_POSTS_REQUEST, requestGetPosts)
}
export function* requestGetPosts(action) {
try {
// ...
}
catch (error) {
// ...
}
}
Strategy
Strategy design pattern lets you switch between algorithms at run-time.
One React example would be, say, you have a text editor that takes text input from the user, which could be in several languages that you allow in your system.
Moreover, you want to process the text and warn the user about the inappropriate language used. You also want to handle this on the frontend side, as roundtrip to the backend API will be inefficient and costly.
Since all languages are totally different, each will require a processing algorithm of its own.
class EnglishTextProcessor {
constructor(text) {
this.text = text;
}
detectProfanities() {
// process this.text written in English, identify and return warning
}
}
class ChineseTextProcessor {
constructor(text) {
this.text = text;
}
detectProfanities() {
// process this.text written in Chinese, identify and return warning
}
}
class RussianTextProcessor {
// ...
}
class ArabicTextProcessor {
// ...
}
class UrduTextProcessor {
// ...
}
class PersianTextProcessor {
// ...
}
class TurkishTextProcessor {
// ...
}
class HindTextProcessor {
// ...
}
const languageProcessor = {
"english": EnglishTextProcessor,
"chinese": ChineseTextProcessor,
"russian": RussianTextProcessor,
"arabic": ArabicTextProcessor,
"urdu": UrduTextProcessor,
"persian": PersianTextProcessor,
"turkish": TurkishTextProcessor,
"hindi": HindTextProcessor,
}
export class LanguageProcessor {
constructor({ language, text }) {
this.languageProcessor = new languageProcessor[language](text);
}
detectProfanities() {
return this.languageProcessor.detectProfanities();
}
}
// Inside warning component
render(){
const lp = new LanguageProcessor({ language: this.state.user.language, text: this.state.typedText })
const profanities = lp.detectProfanities();
return {
profanities? <span style={{color: "red"}}>Please mind your language!</span> : null
}
}
Adapter
When two components can’t work together because of an interface mismatch, we can make them work by introducing an “adapter” in between them. Every now and then, you must’ve experienced the need to change the parameters or results to and from a backend or third-party API, because of the way you’re storing data (say, in a redux store). You’re “adapting” the result or parameters, so that is essentially an adapter pattern although there’s no interface used.
// adapter functions
const addressConvertor = {
toObj: addressArr =>{
return {
lat: addressArr[0],
long: addressArr[1],
address: addressArr[2]
}
},
toArr: addressObj => {
return [
addressObj.lat,
addressObj.long,
addressObj.address
]
},
}
// redux reducer
export const getLiveLocationSuccess = (state, action) => {
...state,
location: {
...action.location
}
}
// Inside some component
componentDidMount() {
getLiveLocation()
.then(response =>{ // response.data has the format ["53.274555", "-7.812395", "Ferbane Industrial Park"]
const addressObj = addressConvertor.toObj(response.data);
// dispatch and save `addressObj` to redux store
})
}
Here toObj
function is an adaptor because our reducer getLiveLocationSuccess
is expecting a location object in the payload and won’t understand an array that we receive from the API.
Conversely, we might need to call an API that expects the same array format for the location. Then we’ll use toArr
as an adaptor before making the API call.
Facade
To encapsulate a complex logic behind a simple interface is known as facade pattern.
Wikipedia gives an intuitive example of starting a computer. A ComputerFacade
class hides the underlying complexity of loading up CPU, Hard drive, and Memory. We just need to create a ComputerFacade instance and call its Start method without taking the headache of managing all the components ourselves.
We must’ve done this time and again without realizing its facade. In a frontend application example, a user clicks on any of the CTA (call to action) buttons or links within the app, and we want to notify Intercom and Slack and increment a total CTA click count in Firestore. We can hide this implementation in a Facade function onCTAClick
and call it CTA click from anywhere in the app, for any CTA click.
const notifyCTAClickIntercom = () => {
Intercom("trackEvent", "cta-click", { ...metadata, clickTime: Date.now()});
};
const notifyCTAClickSlack = ({
url, clickTime = Date.now(), ctaName, CTAType = "button", userCountry,
}) => {
sendSlackNotification({
channel: '#cta-clicks',
message: `${ctaName} ${CTAType} has been clicked at ${url} on ${clickTime} from ${userCountry}`
});
};
const incrementFirestoreCTAClickCount = () => {
const db = firebase.firestore();
const increment = firebase.firestore.FieldValue.increment(1);
const applicationRef = db.collection('application').doc('clicks');
applicationRef.update({ cta: increment });
};
// the Facade function
export const onCTAClick = (metadata) => {
notifyCTAClickIntercom(metadata);
notifyCTAClickSlack(metadata);
incrementFirestoreCTAClickCount();
};
// From any React component throughout the application
onCTAClick({
url,
CTAName,
CTAType,
userCountry,
});
Without encapsulating this in a facade function or class we would need to include and call Intercom, Slack, and Firestore methods separately in every component the CTA clicks are expected.
Proxy
The proxy pattern provides a gateway to another object. It either forwards the call to the real object or processes the request based on some other logic. It has the same exact interface as the real object, so for the client calling either of them it looks the same.
A real-world example is nginx or apache server that sits as proxy server in front of our Node Express application. We could make HTTP requests to the Node Express application directly, and it would work fine, but we make the same calls through a proxy server for additional benefits such as caching.
Let’s take a React JS example. A function calls the backend API to fetch a todo JSON based on id. Usually, this function only calls the API, but we can change it to a proxy method, where it looks up the cache results if not much time has passed. This way, it saves backend calls for the same ids searched for in a short span of time.
const axios = require('axios');
const todoCache = {}
const getTodoFromAPI = todoId => {
return new Promise(resolve => {
axios.get('https://jsonplaceholder.typicode.com/todos/' + todoId)
.then(response => resolve(response.data));
});
};
// the proxy function
const getTodo = todoId => {
return new Promise(resolve => {
if (
todoCache[todoId] &&
todoCache[todoId + "_last_cached"] &&
Date.now() - todoCache[todoId + "_last_cached"] <= 1000
) {
console.log(`TODO Id ${todoId} found in cache!`);
return resolve(todoCache[todoId]);
}
console.log(`TODO Id ${todoId} not found in the cache, or cache expired! Calling the API...`);
getTodoFromAPI(todoId)
.then(function (data) {
const { id } = data;
todoCache[id] = data;
todoCache[id + "_last_cached"] = Date.now();
resolve(data);
});
});
}
// test run proxy function
getTodo(1)
.then(todo => {
return getTodo(1)
})
.then(todo => {
return getTodo(1)
})
.then(todo => {
return getTodo(1)
})
.then(todo => {
return getTodo(2)
})
.then(todo => {
return getTodo(3)
})
.then(todo => {
return getTodo(7)
})
.then(todo => {
return getTodo(1)
});
Here you can see that the client (in React’s case, usually a component) is oblivious to the fact that how is getTodo
function getting the results. It’s up to getTodo
, which is a proxy method, to decide where to fetch the results from, and when.
Notice that we can replace getTodo
directly with getTodoFromAPI
, and that will work too because both require the same parameter (todoId
) and return the same response format (only data
from the response
object).
For each cached result, we add the time it was cached in the _last_cached
field. For the demo, I’ve kept 1 second in this example. The result of the test run above would be:
TODO Id 1 not found in the cache, or cache expired! Calling the API...
TODO Id 1 found in the cache!
TODO Id 1 found in the cache!
TODO Id 1 found in the cache!
TODO Id 2 not found in the cache, or cache expired! Calling the API...
TODO Id 3 not found in the cache, or cache expired! Calling the API...
TODO Id 7 not found in the cache, or cache expired! Calling the API...
TODO Id 1 not found in the cache, or cache expired! Calling the API...
Applying the proxy pattern in React apps for results caching can result in a considerable performance boost. More efficient implementation would involve localStorage and reasonable cache expiry times.
See also
- SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
- Yup Date Format Validation With Moment JS
- Yup Number Validation: Allow Empty String
- Exactly Same Query Behaving Differently in Mongo Client and Mongoose
- JavaScript Unit Testing JSON Schema Validation
- Reduce JS Size With Constant Strings
- JavaScript SDK