Proven Solutions: Implementing React Design Patterns

React design patterns offer software developers two primary benefits. Firstly, they provide a straightforward method to tackle software development challenges using proven solutions. Secondly, they significantly streamline the process of building highly cohesive modules with reduced interdependence. This article delves into the most essential React-specific design patterns and best practices, while also exploring the utility of general design patterns across various React use cases.

Commonly Used React Design Patterns

While general design patterns are applicable in React, React developers benefit most from patterns specifically tailored for the framework. Let’s explore the fundamental ones: higher-order components, providers, compound components, and hooks.

Higher-order Components (HOC)

Higher-order components (HOC) utilize props to furnish reusable logic to components. When there’s a need to augment existing component functionality with a fresh UI, HOCs come into play.

Two boxes representing a component and a higher-order component are combined to create a single box consisting of a component with additional functionality.

Combining a component with a HOC yields the desired outcome: a component enhanced with capabilities beyond the original.

In code, this involves wrapping a component within a HOC, which returns the desired augmented component:

1
2
3
4
5
6
7
8
// 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) 

The logic within HOCs can vary; architecturally, they are prevalent in Redux.

Provider Design Pattern

The provider design pattern helps circumvent prop drilling, the process of passing props down to deeply nested components within a component tree. This pattern is achievable using React’s Context API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 snippet illustrating the provider pattern demonstrates direct prop passing to a newly created object via context. Context encompasses both a state provider and consumer; in this case, the provider is an app component, while a book component consuming BookContext acts as the consumer. Here’s a visual representation:

Two sets of four boxes with each set labeled A through D. The Without Context set shows passing props from A to B, B to C, B to C, C to D. The With Context set passes props directly from A to D.

Direct prop transmission from component A to component D signifies the use of the provider design pattern. Without it, prop drilling would ensue, with B and C acting as intermediaries.

Compound Components

Compound components are essentially sets of interconnected parts that collaborate to deliver a unified functionality. A simple illustration is a card component with its constituent elements.

A card component composed of three rectangles, representing elements labeled Card.Image, Card.Actions, and Card.Content.

This card component comprises an image, actions, and content, all working in conjunction to provide its functionality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 compound component API offers a user-friendly way to articulate relationships between components.

Hooks

React hooks provide a mechanism to manage a component’s state and lifecycle procedures. Introduced in early 2019, with a wider array becoming available in React version 16.8, hooks encompass state, effect, and custom hooks.

React’s state hook (useState) is structured with two elements: the current value and a function to update it based on state changes:

1
const [data, setData] = React.useState(initialData);

Let’s delve into a more concrete example of the state hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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..."
     />
   );
 }

Here, we initialize a state with an empty string as its current value ("") and can modify its value using the onChange handler.

Class-based components also feature effect hooks (useEffect). The useEffect hook’s functionalities mirror those of React’s earlier lifecycle methods: componentDidMount, componentWillMount, and componentDidUpdate.

While skilled React developers are likely well-versed in hooks, HOCs, providers, and compound components, the best engineers also possess a strong grasp of general design patterns, such as proxies and singletons, and recognize their appropriate application within React.

Exploring General Design Patterns in React

General design patterns transcend language and framework constraints, offering solutions adaptable to various situations. This universality simplifies system comprehension and maintenance, while also facilitating communication among developers. By referencing pattern names, software professionals can readily convey and understand high-level design choices.

Design patterns are broadly categorized into three types:

  • Creational
  • Structural
  • Behavioral

These patterns prove valuable in React, and their applicability extends to JavaScript programming in general, making this knowledge highly transferable.

Creational Design Patterns in React

Creational design patterns center around object creation tailored to diverse scenarios, enhancing flexibility and reusability.

Builder Design Pattern

The builder design pattern streamlines object creation by outlining a step-by-step process and delivering the result of these combined steps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
 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} />
  );
}

This pattern decouples the creation of a complex object from its representation, enabling the generation of different representations using the same construction process.

Singleton Design Pattern

The singleton design pattern restricts class instantiation to a single object. A common use case is ensuring only one authentication instance exists when a user selects from multiple login options:

The auth component branches out into three new components based on auth type: GoogleAuth, AppleAuth, and FacebookAuth.

Consider an AuthComponent with a singleton method authInstance that manages types and renders state changes based on the chosen type. An authInstance shared across three components could dictate whether to render Google, Apple, or Facebook authentication components:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 adhering to the singleton pattern should have a single instance and a global access point. Singletons are best suited for situations where:

  • Assigning logical ownership of a single instance is impractical.
  • Lazy initialization of an object is desired.
  • Global access to an instance is unnecessary.

Lazy initialization, a performance optimization technique, delays object creation until absolutely necessary.

Factory Design Pattern

The factory design pattern proves useful when dealing with a superclass and multiple subclasses, where the goal is to return a specific subclass based on provided input. This pattern delegates class instantiation responsibility from the client program to a dedicated factory class.

Object creation is streamlined using this pattern. Imagine a car component customizable into various subcar components by modifying its behavior. The factory pattern leverages both polymorphism and interfaces, as objects (different cars) are created at runtime.

A factory produces an XCar or a YCar based on calculated props such as type, brand, model, and color.

In the code snippet below, we observe abstract cars with carModel, brandName, and color props. The CarFactory, while named as such, houses categories based on brand names. The XCar brand (e.g., Toyota) would create cars with specific attributes, yet still fall under the CarFactory abstraction. This allows for further customization of color, trim level, and engine displacement within the same Car factory component.

This implementation already utilizes inheritance as a blueprint for class components. By providing props to Car objects, we create distinct objects. Polymorphism is also evident, as the code determines the brand and model of each Car at runtime based on the provided types in different scenarios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 defined by an architectural framework and implemented by its users.

Structural Design Patterns in React

Structural design patterns assist React developers in defining relationships between components, enabling grouping and simplification of complex structures.

Facade Design Pattern

The facade design pattern aims to simplify interactions with multiple components by introducing a single, unified API. This abstraction enhances code readability and can help group generic functionalities into specific contexts, proving particularly beneficial in complex systems with intricate interaction patterns.

An icon for support service breaks down into three boxes: : Billing, Tickets, and Orders.

An example is a support department handling various responsibilities, such as verifying billing, support tickets, or order placement.

Let’s consider an API with get, post, and delete methods:

1
2
3
4
5
6
7
class FacadeAPI {
   constructor() { ... }
  
   get() { ... }
   post() { ... }
   delete() { ... }
}

Completing this facade pattern example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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;

A key limitation of the facade pattern is its suitability for scenarios where a subset of clients require a streamlined interface to access the full functionality of a complex subsystem.

Decorator Design Pattern

The decorator design pattern employs layered, wrapper objects to augment the behavior of existing objects without altering their internal workings. This enables infinite layering of components, with outer components dynamically changing their behavior while leaving the base component unaffected. The base component remains a pure function returning a new component without side effects.

HOCs exemplify this pattern. (While memo presents a prime use case for decorator patterns, it’s not covered here due to the numerous good examples available online.)

Exploring decorator patterns in React:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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...
    }
}

Here, canFly is a method usable anywhere without side effects. Decorators can be applied to class components or functions within class or functional components.

While decorators are powerful for writing cleaner and more maintainable React components, HOCs are often preferred over class decorators. Decorators, being an ECMAScript proposal, are subject to change, warranting caution in their use.

Bridge Design Pattern

The bridge design pattern holds significant value in front-end development by separating an abstraction from its implementation, allowing independent modification of both.

This pattern is employed when: runtime implementation binding is desired; excessive classes arise from a coupled interface and numerous implementations; implementation sharing among multiple objects is needed; or mapping of orthogonal class hierarchies is required.

Illustrating the bridge pattern with a TV and controller example:

TV 1, TV 2, and TV 3 are at the top (Implementation), above a line labeled Bridge. Remote 1, Remote 2, and Remote 3  under the Bridge line are labeled Abstraction.

Imagine each TV and remote belonging to different brands. Each remote would be associated with its brand. A Samsung TV would require a Samsung remote; a Sony remote, despite having similar buttons (on, off, channel up, down), wouldn’t work due to differing implementations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 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
      }
    />
  );
};

The bridge design pattern necessitates maintaining correct references reflecting accurate changes.

Proxy Design Pattern

The proxy design pattern introduces a proxy object acting as a surrogate or placeholder for accessing another object. A relatable example is a credit card representing physical cash or bank account funds.

A cash register labeled Payment above two payment options icons: a credit card (labeled Proxy) and cash (labeled Real Object) linked by an arrow which represents that the credit card is a proxy for cash.

Demonstrating this pattern in code with a funds transfer example where a payment application verifies the available bank account balance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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))

Here, the payment app’s balance verification acts as the proxy.

Behavioral Design Patterns in React

Behavioral design patterns focus on inter-component communication, making them particularly relevant to React’s component-centric nature.

State Design Pattern

The state design pattern is widely used to incorporate basic encapsulation units (states) in component programming. A TV’s behavior changing based on remote control input exemplifies this pattern:

Two TV sets at the top, one is on, one is off (labeled Behavior) are above a line labeled State, and a remote controller labeled Props.

The TV’s state (on or off) changes based on the state of the remote button. Similarly, in React, component state is altered based on props or other conditions.

An object’s behavior is modified when its state changes:

1
2
3
4
5
// Without state property.
<WithoutState otherProps={...otherProps} state={null}/>

// With state property.
<WithState otherProps={...otherProps} state={...state} />

The WithState component behaves differently in these examples depending on whether a state prop is provided or set to null. This state-dependent behavior change is why it’s classified as a behavioral pattern.

Command Design Pattern

The command design pattern excels in designing clean, decoupled systems, enabling execution of business logic at a future point. This pattern is particularly interesting as it underpins Redux. Let’s see its application with a Redux reducer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
   }
}

The Redux reducer in this example contains multiple cases triggered by different events, each returning distinct behaviors.

Observer Design Pattern

The observer design pattern allows objects to subscribe to state changes in another object, receiving automatic notifications upon such changes. This decouples observing objects from the observed, promoting modularity and flexibility.

In Model-View-Controller (MVC) architecture, this pattern is commonly used to propagate model changes to views, enabling view updates without direct access to the model’s internal data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const Observer = () => {
    useEffect(() => {
       const someEventFunc = () => { ... }
      
       // Add event listener.
       documentListener('EVENT_TRIGGER_NAME', () => { ... })
  
       return () => {
           // Remove event listener.
           documentListener('EVENT_TRIGGER_NAME', () => { ... })
       }
    }, [])
}

While the observer pattern uses “observer” and “subject” objects to manage communication, other patterns like the mediator encapsulate communication within an object. Creating reusable observables is generally easier than reusable mediators, but mediators can leverage observers for dynamic colleague registration and communication.

Strategy Design Pattern

The strategy design pattern enables dynamic behavior modification from the outside without altering the base component. It defines a family of algorithms, encapsulates each, and makes them interchangeable, allowing independent changes in the parent component and its children. Abstraction resides in an interface, while implementation details are hidden within derived classes:

1
2
3
4
5
6
7
8
9
const Strategy = ({ children }) => {
   return <div>{children}</div>;
};

const ChildComp = () => {
   return <div>ChildComp</div>;
};

<Strategy children={<ChildComp />} />;

As the open-closed principle dominates object-oriented design, this pattern helps adhere to OOP principles while maintaining runtime flexibility.

Memento Design Pattern

The memento design pattern captures and externalizes an object’s internal state for later restoration without violating encapsulation. It involves:

  • Originator: The object capable of saving its state.
  • Caretaker: Manages the conditions under which the originator saves and restores its state.
  • Memento: Stores the originator’s state, managed by the caretaker and accessed by the originator.

Illustrating with a code example. The memento pattern utilizes the chrome.storage API (implementation details omitted) for data storage and retrieval. This conceptual example showcases data setting in setState and loading in getState:

1
2
3
4
5
6
class Memento {
   // Stores the data.
   setState(){ ... }
   // Loads the data.
   getState() { ... }
}

However, the practical React implementation looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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.
  },
});

Here, getState is returned within the organizer (handler), while a subset of the state is returned in the two logical branches of careTaker.

Significance of React Patterns

While patterns offer proven solutions to recurring problems, software engineers must carefully consider their benefits and drawbacks before implementation.

React developers frequently employ state, hooks, custom hooks, and the Context API. However, understanding and utilizing the general design patterns discussed here elevates their foundational skills, benefiting them across multiple languages. These patterns empower developers to articulate code behavior architecturally, going beyond simply addressing specific issues.

The editorial team at Toptal Engineering Blog expresses gratitude to Teimur Gasanov for reviewing the code samples and technical content in this article.

Licensed under CC BY-NC-SA 4.0