Design patterns in React/JavaScript with real use cases. Part 2

Pryvalov Bogdan
6 min readAug 6, 2023

--

Observer

Pattern Observer widely used in JS. The document.addEventListener is example of the Observer pattern in pure JS, where you subscribe to events and handle them with event listeners:

// Observer 1
function handleClick1() {
console.log('Button clicked (Observer 1)');
}

// Observer 2
function handleClick2() {
console.log('Button clicked (Observer 2)');
}

// Subscribe observers to the click event
document.addEventListener('click', handleClick1);
document.addEventListener('click', handleClick2);

In the Observer pattern, the document object acts as the subject (or event source), and you can register multiple event listeners (observers) for various events (e.g., click, keyup, scroll, etc.). When an event occurs, the subject (document) notifies all subscribed event listeners (observers) by triggering their associated callback functions.

Another example of the Observer pattern is the MutationObserver. It allows you to observe and react to changes in the DOM (Document Object Model) by subscribing to specific DOM mutations.

const targetNode = document.getElementById('target');

// Observer: Handle DOM mutations
const observer = new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('DOM Mutation occurred - childList');
} else if (mutation.type === 'attributes') {
console.log('DOM Mutation occurred - attributes');
}
}
});

// Options for the observer (specify what to observe)
const config = { childList: true, attributes: true };

// Start observing the target node
observer.observe(targetNode, config);

// Trigger some DOM mutations for demonstration purposes
targetNode.appendChild(document.createElement('span'));
targetNode.setAttribute('data-custom-attr', 'custom-value');

The MutationObserver acts as the subject (event source), and you can register multiple observer objects (observers) that will be notified when DOM mutations occur. When a mutation happens, the MutationObserver triggers the callback function of each registered observer, providing them with information about the changes that occurred in the DOM.

We can consider that The WebSocket is also and example of Observer pattern.

Server-side:

//Server

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

const subscribers = new Set();

wss.on('connection', (ws) => {
// Add new client to the subscribers set
subscribers.add(ws);

// Observer: Handle incoming messages from clients
ws.on('message', (data) => {
console.log(`Received message from client: ${data}`);
});

// Notify observer about an event (e.g., every 5 seconds)
setInterval(() => {
broadcast('Server sending data to all clients');
}, 5000);

// Remove the client from subscribers when disconnected
ws.on('close', () => {
subscribers.delete(ws);
});
});

function broadcast(message) {
// Send message to all connected clients (subscribers)
for (const client of subscribers) {
client.send(message);
}
}

The server acts as the subject (event source), and the connected clients act as observers. When the server sends data to the connected clients, they are notified and can respond accordingly by executing callback functions to handle the incoming data.

Client-side:

const ws = new WebSocket('ws://localhost:8080');

// Observer: Handle incoming messages from the server
ws.onmessage = (event) => {
console.log(`Received from server: ${event.data}`);
};

In React the typical case of usage Observer pattern is State Management. Popular state management libraries like Redux and MobX use the Observer pattern to manage the application’s state. Components subscribe to the state store, and when the state updates, all subscribed components are notified and rerendered with the latest state.

// Redux-like store
const createStore = (reducer) => {
let state;
let subscribers = [];

const getState = () => state;

const dispatch = (action) => {
state = reducer(state, action);
subscribers.forEach((subscriber) => subscriber());
};

const subscribe = (subscriber) => {
subscribers.push(subscriber);
return () => {
subscribers = subscribers.filter((sub) => sub !== subscriber);
};
};

dispatch({}); // Initialize state

return { getState, dispatch, subscribe };
};

// Reducer
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'RESET':
return 0;
default:
return state;
}
};

// Create a store using the reducer
const store = createStore(counterReducer);

// React Counter Component subscribing to the store
function Counter() {
const [count, setCount] = useState(store.getState());

useEffect(() => {
const unsubscribe = store.subscribe(() => {
setCount(store.getState());
});
return () => {
unsubscribe();
};
}, []);

const handleIncrement = () => {
store.dispatch({ type: 'INCREMENT' });
};

const handleReset = () => {
store.dispatch({ type: 'RESET' });
};

return (
<div>
<h2>Counter</h2>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}

React’s event handling system also employs the Observer pattern. Components register event listeners to specific events same was as in pure JS, and when those events occur, the subscribed components receive updates and respond accordingly.

Proxy

In pure JS there is the Proxy object which allows you to create an object that can be used in place of the original object.

function expensiveFunction(n) {
console.log(`Computing for ${n}...`);
// Simulating some expensive computation
return n * 2;
}

function createMemoizationProxy(targetFunction) {
const cache = new Map();

return new Proxy(targetFunction, {
apply(target, thisArg, args) {
const key = args.toString();
if (cache.has(key)) {
console.log(`Returning cached result for ${args}`);
return cache.get(key);
}

const result = target.apply(thisArg, args);
cache.set(key, result);
return result;
},
});
}

const memoizedExpensiveFunction = createMemoizationProxy(expensiveFunction);

console.log(memoizedExpensiveFunction(5));
console.log(memoizedExpensiveFunction(10));
console.log(memoizedExpensiveFunction(5));

We can use the Proxy pattern to create a memoization proxy that automatically caches function results and returns the cached result if the same arguments are provided again.

In React as a good example of Proxy pattern could be Debounce:

import React, { useState, useEffect } from 'react';

// Simulating backend request for data
function fetchData(selectedCheckboxes) {
return new Promise((resolve) => {
setTimeout(() => {
const data = selectedCheckboxes.map((checkbox) => ({
name: `Data for checkbox ${checkbox}`,
}));
resolve(data);
}, 500);
});
}

function Checkbox({ label, checked, onChange }) {
return (
<div>
<input type="checkbox" checked={checked} onChange={onChange} />
<label>{label}</label>
</div>
);
}

function App() {
const [selectedCheckboxes, setSelectedCheckboxes] = useState([]);
const [data, setData] = useState([]);

// Debounce function to optimize backend calls
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
};

// Fetch data when selectedCheckboxes change
useEffect(() => {
const fetchDataDebounced = debounce(() => {
const promises = selectedCheckboxes.map((checkboxId) => fetchData(checkboxId));

Promise.all(promises).then((responseData) => {
setData(responseData);
});
}, 100); // Debounce the request for 100ms
}, [selectedCheckboxes]);

const handleCheckboxChange = (event, checkbox) => {
const isChecked = event.target.checked;
if (isChecked) {
setSelectedCheckboxes([...selectedCheckboxes, checkbox]);
} else {
setSelectedCheckboxes(selectedCheckboxes.filter((cb) => cb !== checkbox));
}
};

return (
<div>
<Checkbox
label="Checkbox 1"
checked={selectedCheckboxes.includes(1)}
onChange={(e) => handleCheckboxChange(e, 1)}
/>
<Checkbox
label="Checkbox 2"
checked={selectedCheckboxes.includes(2)}
onChange={(e) => handleCheckboxChange(e, 2)}
/>
<Checkbox
label="Checkbox 3"
checked={selectedCheckboxes.includes(3)}
onChange={(e) => handleCheckboxChange(e, 3)}
/>
<Checkbox
label="Select All"
checked={selectedCheckboxes.length === 3}
onChange={(e) =>
e.target.checked
? setSelectedCheckboxes([1, 2, 3])
: setSelectedCheckboxes([])
}
/>
<div>
{data.map((item, index) => (
<p key={index}>{item.name}</p>
))}
</div>
</div>
);
}

export default App;

For example we have three checkboxes that send requests for data when clicked, and you want to optimize the backend calls using a proxy request that waits for 100ms to batch multiple checkbox selections into a single request, you can use React’s useState and useEffect hooks to manage the checkbox states and the debounce pattern for the proxy request.

Facade

The Facade pattern is commonly used in real-world scenarios where there is a need to simplify and provide a unified interface to a complex system or set of APIs.

// Complex API
class API {
static fetchData(endpoint) {
console.log(`Fetching data from ${endpoint}`);
return fetch(endpoint).then((response) => response.json());
}
}

// Facade for the API
class APIFacade {
static getUserData() {
return API.fetchData('/api/users');
}

static getProductData() {
return API.fetchData('/api/products');
}
}

// Usage
APIFacade.getUserData().then((userData) => console.log(userData));
APIFacade.getProductData().then((productData) => console.log(productData));

In this scenario, we have a complex API represented by the API class, which fetches data from different API endpoints. The APIFacade acts as a Facade that provides a simpler interface to fetch user data and product data using the API.

In React, the Facade pattern is often used to provide a simple interface to a complex subsystem of components. This pattern promotes simplicity and reduces dependencies between components.

import React, { useState } from 'react';

// Subcomponents that handle specific aspects of the form
import NameInput from './NameInput';
import EmailInput from './EmailInput';
import AgeInput from './AgeInput';
import SubmitButton from './SubmitButton';

// Facade Component
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState('');

const handleSubmit = () => {
console.log('Submitted Form Data:', { name, email, age });
};

return (
<div>
<h2>Form</h2>
<NameInput value={name} onChange={setName} />
<EmailInput value={email} onChange={setEmail} />
<AgeInput value={age} onChange={setAge} />
<SubmitButton onClick={handleSubmit} />
</div>
);
}

export default ComplexForm;

In this example, the Form component acts as the facade and encapsulates the complex form logic and multiple input fields (NameInput, EmailInput, AgeInput) and the SubmitButton. Other components in the application can use the Form component without worrying about the inner details of how the form works or how the data processing is handled.

Conclusion

I tried to show use cases for design patterns in the pure JS and React. How some methods are using patterns under the hood. I hope this was helpful.

If you have any suggestions fell free to add a comment.

--

--