Flexible and Maintainable React / React Native Applications with the Decorator Pattern
In the world of software development, there are many design patterns that developers can use to build better and more maintainable applications. One such pattern is the Decorator Design Pattern. In this article, we will explore what the Decorator pattern is, why and when it should be used, and how it can be implemented in a React Native application using TypeScript.
We will also provide real-life examples that illustrate how the Decorator pattern can be used to solve common problems when working with backend APIs.
What is the Decorator Design Pattern? The Decorator pattern is a structural design pattern that allows developers to add new behavior to an existing object dynamically without changing its original structure. This pattern is commonly used in situations where developers want to add new functionality or behavior to a class without modifying its existing code. In essence, the Decorator pattern lets developers wrap an object with one or more decorators that add new features or modify existing ones.
When should you use decorator pattern
There are several reasons why a developer might choose to use the Decorator pattern.
- Extending functionality without modifying existing code: When you need to add new functionality to an existing object without changing its existing code, the Decorator pattern can be a good solution. You can wrap the object in one or more Decorator objects that add the desired behavior, without affecting the original object.
- Creating flexible and reusable code: The Decorator pattern allows you to create a flexible and reusable code structure. You can add or remove Decorator objects at runtime, which makes it easy to modify the behavior of an object as needed.
- Implementing open-closed principle: The Decorator pattern supports the open-closed principle, which states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. The Decorator pattern allows you to extend the functionality of an object without modifying its code, which helps maintain code stability and minimize the risk of introducing new bugs.
- Supporting composition over inheritance: The Decorator pattern supports composition over inheritance, which means that it’s often a better choice to add new behavior by composing objects rather than inheriting behavior from a base class. With the Decorator pattern, you can easily compose objects to create complex behavior.
Usage of Decorator Pattern in React Native
To illustrate how the Decorator pattern can be used in a React Native application, I will provide two real-life examples.
Using the Decorator Pattern to Add New Behavior to a Component
we will use the Decorator pattern to add new behavior to a React Native component. The component we will use is a simple list of products fetched from a backend API. We will create a decorator that adds new formatting behavior to the component.
import React, { FC } from 'react';
import { View, Text } from 'react-native';
// Define the Component interface
interface Component {
render: () => JSX.Element;
}
// Define a concrete component
const ConcreteComponent: FC = () => {
return (
<View>
<Text>Concrete Component</Text>
</View>
);
};
// Define a base decorator class that implements the Component interface
abstract class Decorator implements Component {
protected component: Component;
constructor(component: Component) {
this.component = component;
}
abstract render(): JSX.Element;
}
// Define a concrete decorator that adds new behavior
class ConcreteDecorator extends Decorator {
render(): JSX.Element {
const component = this.component.render();
return (
<View>
<Text>Decorator Text</Text>
{component}
</View>
);
}
}
// Usage
const concreteComponent: Component = { render: ConcreteComponent };
const decoratedComponent: Component = new ConcreteDecorator(concreteComponent);
// Render the decorated component
decoratedComponent.render();
In the code example above, we start by defining the Component
interface as a class. We then define a concrete component called ConcreteComponent
that implements this interface.
Next, we define an abstract class called Decorator
that implements the Component
interface and has a reference to a Component
object. This class acts as a base class for all decorators.
We then define a concrete decorator called ConcreteDecorator
that extends the Decorator
class and adds new behavior to the render()
method.
Finally, we create an instance of the ConcreteComponent
class and pass it to the ConcreteDecorator
constructor to create a decorated component. We can then call the render()
method on the decorated component to see the new behavior added by the decorator.
Below is the tests for above example.
//Tests
import { FC } from 'react';
describe('Decorator Pattern - Example 1', () => {
interface Component {
render: () => JSX.Element;
}
const ConcreteComponent: FC = () => {
return <div>Concrete Component</div>;
};
abstract class Decorator implements Component {
protected component: Component;
constructor(component: Component) {
this.component = component;
}
abstract render(): JSX.Element;
}
class ConcreteDecorator extends Decorator {
render(): JSX.Element {
const component = this.component.render();
return (
<>
<div>Decorator Text</div>
{component}
</>
);
}
}
const concreteComponent: Component = { render: ConcreteComponent };
const decoratedComponent: Component = new ConcreteDecorator(concreteComponent);
it('renders ConcreteComponent with text', () => {
expect(concreteComponent.render()).toEqual(<div>Concrete Component</div>);
});
it('renders ConcreteDecorator with text', () => {
expect(decoratedComponent.render()).toEqual(
<>
<div>Decorator Text</div>
<div>Concrete Component</div>
</>,
);
});
});
Using the Decorator Pattern to Format Data from a Backend API
Imagine that you are building a mobile app that interacts with a backend API to fetch and display data. You have a set of components that display different types of data, such as a list of products, a list of orders, and a list of customers. Each of these components needs to fetch data from the API and display it.
One issue that you might encounter is that the data returned by the API may not always be in the format that your components expect. For example, the API may return data in a different structure or with different field names than what your components are designed to handle. This can lead to errors in your app or require you to write custom parsing code in each component.
To solve this problem, you can use the Decorator pattern to create a set of data formatting decorators that can be applied to each component. Each decorator would be responsible for formatting the data in a specific way, such as renaming fields or restructuring the data.
import React, { Component } from 'react';
import { View, Text } from 'react-native';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductListComponentProps {
products: Product[];
}
class ProductListComponent extends Component<{}, ProductListComponentProps> {
state = {
products: [],
};
async componentDidMount() {
const products = await fetch('/api/products');
this.setState({ products });
}
render() {
const { products } = this.props;
return (
<View>
<Text>Product List</Text>
{products.map((product: Product) => (
<View key={product.id}>
<Text>{product.name}</Text>
<Text>{product.price}</Text>
</View>
))}
</View>
);
}
}
type ProductDataFormatter = (component: Component) => Component;
const ProductDataFormatter: ProductDataFormatter = (component) => {
return class extends Component {
state = {
products: [],
};
async componentDidMount() {
const products = await fetch('/api/products');
const formattedProducts = formatProductData(products); // Format the data using a helper function
this.setState({ products: formattedProducts });
}
render() {
const { products } = this.state;
return React.createElement(component, { products });
}
};
};
const DecoratedProductListComponent = ProductDataFormatter(ProductListComponent);
<DecoratedProductListComponent />;
//Tests
import { Component } from 'react-native';
describe('Decorator Pattern - Example 2', () => {
interface Product {
id: number;
name: string;
price: number;
}
interface ProductListComponentProps {
products: Product[];
}
class ProductListComponent extends Component<{}, ProductListComponentProps> {
state = {
products: [],
};
async componentDidMount() {
const products = await fetch('/api/products');
const json = await products.json();
this.setState({ products: json });
}
render() {
const { products } = this.props;
return (
<>
<Text>Product List</Text>
{products.map((product: Product) => (
<View key={product.id}>
<Text>{product.name}</Text>
<Text>{product.price}</Text>
</View>
))}
</>
);
}
}
type ProductDataFormatter = (component: Component) => Component;
const formatProductData = (products: Product[]): Product[] => {
return products.map((product) => {
return {
...product,
name: product.name.toUpperCase(),
price: product.price.toFixed(2),
};
});
};
const ProductDataFormatter: ProductDataFormatter = (component) => {
return class extends Component {
state = {
products: [],
};
async componentDidMount() {
const products = await fetch('/api/products');
const json = await products.json();
const formattedProducts = formatProductData(json);
this.setState({ products: formattedProducts });
}
render() {
const { products } = this.state;
return React.createElement(component, { products });
}
};
};
const DecoratedProductListComponent = ProductDataFormatter(ProductListComponent);
it('renders ProductListComponent with unformatted data', () => {
const wrapper = shallow(<ProductListComponent products={[{ id: 1, name: 'Product 1', price: 10.99 }]} />);
expect(wrapper.find('Text').at(0).prop('children')).toBe('Product List');
expect(wrapper.find('Text').at(1).prop('children')).toBe('Product 1');
expect(wrapper.find('Text').at(2).prop('children')).toBe(10.99);
});
it('renders DecoratedProductListComponent with formatted data', async () => {
const wrapper = shallow(<DecoratedProductListComponent />);
await wrapper.instance().componentDidMount();
wrapper.update();
expect(wrapper.find('Text').at(0).prop('children')).toBe('Product List');
expect(wrapper.find('Text').at(1).prop('children')).toBe('PRODUCT 1');
expect(wrapper.find('Text').at(2).prop('children')).toBe('10.99');
});
});
In the code example above, we start by defining a ProductListComponent
that displays a list of products fetched from the API. We then define a ProductDataFormatter
decorator that formats the product data returned by the API using a helper function called formatProductData
. The decorator returns a new component that passes the formatted data to the original component as a prop.
We then create a DecoratedProductListComponent
by calling the ProductDataFormatter
decorator and passing in the ProductListComponent
as a prop. When the DecoratedProductListComponent
is rendered, it fetches and formats the product data before passing it to the ProductListComponent
as a prop.
The Decorator Design Pattern is a valuable tool for developers to add new functionality to an object without modifying its existing code. In React Native applications, the Decorator pattern can help simplify complex code, extend component behavior, and format data from backend APIs.
Using the Decorator pattern can make React Native applications more modular, flexible, and maintainable, ultimately making them easier to work with and extend. The article concludes by encouraging developers to use the Decorator pattern to solve common problems in React Native development.
Your feedback is important to me! If you enjoyed my article, please give it a 👏 and consider following me for more informative and insightful content.