Design Patterns in React: Harnessing JavaScript Best Practices for Scalable Applications - prototype design pattern
For a dynamic language like JavaScript, the prototype pattern is not just a design pattern. It is a key part of JavaScript’s object-oriented system. In modern front-end development, React prefers function components and Hooks. But React still relies on JavaScript’s prototype system underneath. Today, we will talk about the prototype pattern. We will look at it with real React examples and explore how it works.
What Is the Prototype Pattern?
In design patterns, the prototype pattern is a creation pattern. Its main idea is to copy an existing object to make a new one. This is different from building an object from scratch using a class or constructor. This approach works well when you need to create similar objects often. It saves time by avoiding repeated setup work.
In JavaScript, the prototype pattern fits naturally because of its prototype chain inheritance. Every JavaScript object has a __proto__
property (or you can use Object.getPrototypeOf()
to find it). This points to its prototype object.
When we try to use a property or method on an object, JavaScript checks the object first. If it’s not there, it looks up the prototype chain until it finds it. This system lets us add new behaviors to objects easily.
Here’s a simple example:
const person = {
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};
const cris = Object.create(person);
cris.name = "Cris.Wang";
cris.greet(); // Prints: Hello, I'm Cris.Wang
Here, Object.create()
makes a new object called cris. Its prototype points to person. Through the prototype chain, cris gets the greet method. We only add the name property to make it unique. This is the basic idea of the prototype pattern.
How the Prototype Pattern Works in JavaScript
In JavaScript, you can use the prototype pattern in many ways. These include constructors, prototype objects, Object.create()
, and ES6 class (which is still based on prototypes). Let’s look at each one.
Using Constructors and Prototype Objects
Traditional JavaScript uses constructors with the prototype property for inheritance:
function Animal(name) {
this.name = name;
}
Animal.prototype.say = function() {
console.log(`${this.name} says hello`);
};
const dog = new Animal("Dog");
dog.say(); // Prints: Dog says hello
Here, Animal.prototype
is the shared prototype for all instances made with Animal. The dog object gets the say method, but name belongs to the instance itself.
Using Object.create()
Object.create()
is a direct way to use the prototype pattern. It lets us pick an object to be the prototype of a new object:
const vehicle = {
move() {
console.log(`${this.type} is moving`);
}
};
const car = Object.create(vehicle);
car.type = "Car";
car.move(); // Prints: Car is moving
This method is simple and clear. It’s great for making single objects or basic inheritance.
ES6 Class Syntax
ES6 added class syntax. It looks like traditional object-oriented languages, but it still uses prototypes underneath:
class Shape {
constructor(type) {
this.type = type;
}
draw() {
console.log(`Drawing a ${this.type}`);
}
}
const circle = new Shape("circle");
circle.draw(); // Prints: Drawing a circle
Even with modern syntax, Shape.prototype.draw
is still the key to the prototype chain.
Using the Prototype Pattern in React
React leans toward function components now. But class components and prototypes still matter in some cases. Let’s look at a few real examples of how prototypes work with React.
Reusing Methods in Class Components
In React class components, the prototype pattern can share method logic. For example, say we have a base component class with common state logic:
Combining Higher-Order Components (HOC) with Prototypes
Higher-order components are a common way to reuse logic in React. Prototypes can help support them. Suppose we need a general data-loading component:
function withDataLoader(WrappedComponent) {
return class DataLoader extends React.Component {
state = { data: null };
async componentDidMount() {
const data = await this.fetchData();
this.setState({ data });
}
};
}
withDataLoader.prototype.fetchData = async function() {
// Fake data fetch
return new Promise(resolve => setTimeout(() => resolve("Loaded data"), 1000));
};
Then, we can use it with a specific component:
class Display extends React.Component {
render() {
return <div>{this.props.data || "Loading..."}</div>;
}
}
const EnhancedDisplay = withDataLoader(Display);
Here, fetchData is on the prototype of the class returned by withDataLoader. All components using this HOC share this method. If we need something custom, a child component can override fetchData:
class CustomLoader extends withDataLoader(Display) {
async fetchData() {
return "Custom data";
}
}
This mixes HOC flexibility with prototype reuse.
Function Components and Indirect Prototype Use
Function components don’t have prototypes themselves. But we can use prototypes indirectly with custom objects and factory patterns. For example, we can create a shared behavior object:
const formUtils = {
validateField(value) {
return value.length > 0 ? "" : "Field cannot be empty";
}
};
const createFormField = () => Object.create(formUtils);
Then use it in a React function component:
function FormComponent() {
const nameField = createFormField();
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
const handleChange = (e) => {
setValue(e.target.value);
setError(nameField.validateField(e.target.value));
};
return (
<div>
<input value={value} onChange={handleChange} />
{error && <span>{error}</span>}
</div>
);
}
Here, formUtils
is the prototype object. Instances from createFormField reuse its methods. If we add new methods to the prototype, all instances get them too.
Benefits and Things to Watch Out For
With Hooks and function components getting popular, the React community uses class components less. But the prototype pattern is still useful. In cases with complex inheritance or shared logic, class components or custom objects with prototypes work well. One thing to note is that properties on a prototype are shared. Changing them affects all instances, which can cause problems. For example:
const proto = { items: [] };
const obj1 = Object.create(proto);
const obj2 = Object.create(proto);
obj1.items.push(1);
console.log(obj2.items); // Prints: [1]