Template Method Pattern
The Template method design pattern is a behavioral design pattern that defines the skeleton of an algorithm in an abstract class, and lets subclasses implement some steps of the algorithm without changing its structure. The template method is a method in the abstract class that contains the invariant parts of the algorithm and calls other abstract or hook methods that are implemented by the subclasses. This way, the template method ensures that the algorithm is always followed, while allowing subclasses to customize some aspects of it.
Use cases
Some use cases for the template method design pattern are:
- When you want to reuse the common code of similar algorithms in a superclass, and only change some details in subclasses. For example, you can define a generic data processing algorithm in an abstract class, and let subclasses implement different data sources and formats.
- When you want to control the extension points of an algorithm in a superclass, and prevent subclasses from overriding certain steps. For example, you can define a web scraping algorithm in an abstract class, and let subclasses implement different selectors and parsers, but not change the request or response handling logic.
- When you want to apply the Hollywood principle: “Don’t call us, we’ll call you”. This means that instead of letting subclasses call methods from a superclass, the superclass calls methods from subclasses. This way, you can avoid tight coupling and dependency inversion problems. For example, you can define a UI component rendering algorithm in an abstract class, and let subclasses implement different styles and behaviors, but not change the DOM manipulation logic.
Here are some examples of the template method design pattern in React, TypeScript and functional components:
1. Data Fetching Component
Suppose you want to create a reusable component that fetches some data from an API and renders it in a list. However, you want to allow different components to fetch different data sources and render different list items. You can use the template method pattern to achieve this.
First, you create an abstract component that defines the template method and the abstract methods for fetching and rendering data:
// AbstractDataFetcher.tsx
import React, { useEffect, useState } from "react";
type Data = any; // You can use a more specific type if you want
// Convert the AbstractDataFetcher class component to a functional component
export function AbstractDataFetcher({
fetchData,
renderItem,
}: {
fetchData: () => Promise<Data[]>;
renderItem: (data: Data) => JSX.Element;
}) {
// Use the useState hook to manage the data state
const [data, setData] = useState<Data[]>([]);
// Fetch data using the fetchData prop
useEffect(() => {
fetchData().then((data) => setData(data));
}, [fetchData]);
// Render data using the renderItem prop
return <ul>{data.map(renderItem)}</ul>;
}// AbstractDataFetcher.tsx
import React, { useEffect, useState } from "react";
type Data = any; // You can use a more specific type if you want
// Convert the AbstractDataFetcher class component to a functional component
export function AbstractDataFetcher({
fetchData,
renderItem,
}: {
fetchData: () => Promise<Data[]>;
renderItem: (data: Data) => JSX.Element;
}) {
// Use the useState hook to manage the data state
const [data, setData] = useState<Data[]>([]);
// Fetch data using the fetchData prop
useEffect(() => {
fetchData().then((data) => setData(data));
}, [fetchData]);
// Render data using the renderItem prop
return <ul>{data.map(renderItem)}</ul>;
}Then, you create concrete components that inherit from the abstract component and implement the abstract methods:
// UserFetcher.tsx
import React from "react";
import { AbstractDataFetcher } from "./AbstractDataFetcher";
type User = {
id: number;
name: string;
email: string;
};
// Convert the UserFetcher class component to a functional component
export function UserFetcher() {
// Define the helper functions as regular functions
async function fetchData(): Promise<User[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const data = await response.json();
return data;
}
function renderItem(user: User): JSX.Element {
return (
<li key={user.id}>
{user.name} ({user.email})
</li>
);
}
// Return the AbstractDataFetcher component with the helper functions as props
return <AbstractDataFetcher fetchData={fetchData} renderItem={renderItem} />;
}// UserFetcher.tsx
import React from "react";
import { AbstractDataFetcher } from "./AbstractDataFetcher";
type User = {
id: number;
name: string;
email: string;
};
// Convert the UserFetcher class component to a functional component
export function UserFetcher() {
// Define the helper functions as regular functions
async function fetchData(): Promise<User[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const data = await response.json();
return data;
}
function renderItem(user: User): JSX.Element {
return (
<li key={user.id}>
{user.name} ({user.email})
</li>
);
}
// Return the AbstractDataFetcher component with the helper functions as props
return <AbstractDataFetcher fetchData={fetchData} renderItem={renderItem} />;
}// PostFetcher.tsx
import React from "react";
import { AbstractDataFetcher } from "./AbstractDataFetcher";
type Post = {
id: number;
title: string;
body: string;
};
export function PostFetcher() {
// Implement the fetchData method to fetch posts from an API
async function fetchData(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const data = await response.json();
return data;
}
// Implement the renderItem method to render a post as a list item
function renderItem(post: Post): JSX.Element {
return (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
);
}
// Return the AbstractDataFetcher component with the helper functions as props
return <AbstractDataFetcher fetchData={fetchData} renderItem={renderItem} />;
}// PostFetcher.tsx
import React from "react";
import { AbstractDataFetcher } from "./AbstractDataFetcher";
type Post = {
id: number;
title: string;
body: string;
};
export function PostFetcher() {
// Implement the fetchData method to fetch posts from an API
async function fetchData(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const data = await response.json();
return data;
}
// Implement the renderItem method to render a post as a list item
function renderItem(post: Post): JSX.Element {
return (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
);
}
// Return the AbstractDataFetcher component with the helper functions as props
return <AbstractDataFetcher fetchData={fetchData} renderItem={renderItem} />;
}Finally, you can use these concrete components in your app:
// App.tsx
import React from "react";
import { UserFetcher } from "./UserFetcher";
import { PostFetcher } from "./PostFetcher";
function App() {
return (
<div>
<h1>Users</h1>
<UserFetcher />
<h1>Posts</h1>
<PostFetcher />
</div>
);
}
export default App;// App.tsx
import React from "react";
import { UserFetcher } from "./UserFetcher";
import { PostFetcher } from "./PostFetcher";
function App() {
return (
<div>
<h1>Users</h1>
<UserFetcher />
<h1>Posts</h1>
<PostFetcher />
</div>
);
}
export default App;2. Form component:
Suppose you want to create a reusable component that renders a form with some inputs and a submit button. However, you want to allow different components to have different inputs and validation rules. You can use the template method pattern to achieve this.
First, you create an abstract component that defines the template method and the abstract methods for rendering and validating inputs:
// AbstractForm.tsx
import React, { FormEvent, useState } from "react";
type Input = {
name: string;
value: string;
};
// Define the props type for the abstract form component
type AbstractFormProps = {
// The prop for getting the initial inputs
getInitialInputs: () => Input[];
// The prop for rendering an input label
renderInput: (input: Input) => JSX.Element;
// The prop for validating the inputs
validateInputs: (inputs: Input[]) => boolean;
// The prop for submitting the form
submitForm: (inputs: Input[]) => void;
};
// Define the abstract form component as a functional component
export function AbstractForm({
getInitialInputs,
renderInput,
validateInputs,
submitForm,
}: AbstractFormProps) {
// Use the useState hook to manage the inputs state
const [inputs, setInputs] = useState<Input[]>(getInitialInputs());
// Define the event handlers as regular functions
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target;
setInputs((inputs) =>
inputs.map((input) => (input.name === name ? { ...input, value } : input))
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (validateInputs(inputs)) {
submitForm(inputs);
} else {
alert("Invalid inputs");
}
}
// Return the JSX element as usual
return (
<form onSubmit={handleSubmit}>
{inputs.map(renderInput).map((input) => (
<div key={input.props.name}>
{input}
<input
type='text'
name={input.props.name}
value={inputs.find((i) => i.name === input.props.name)?.value || ""}
onChange={handleChange}
/>
</div>
))}
<button type='submit'>Submit</button>
</form>
);
}// AbstractForm.tsx
import React, { FormEvent, useState } from "react";
type Input = {
name: string;
value: string;
};
// Define the props type for the abstract form component
type AbstractFormProps = {
// The prop for getting the initial inputs
getInitialInputs: () => Input[];
// The prop for rendering an input label
renderInput: (input: Input) => JSX.Element;
// The prop for validating the inputs
validateInputs: (inputs: Input[]) => boolean;
// The prop for submitting the form
submitForm: (inputs: Input[]) => void;
};
// Define the abstract form component as a functional component
export function AbstractForm({
getInitialInputs,
renderInput,
validateInputs,
submitForm,
}: AbstractFormProps) {
// Use the useState hook to manage the inputs state
const [inputs, setInputs] = useState<Input[]>(getInitialInputs());
// Define the event handlers as regular functions
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target;
setInputs((inputs) =>
inputs.map((input) => (input.name === name ? { ...input, value } : input))
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (validateInputs(inputs)) {
submitForm(inputs);
} else {
alert("Invalid inputs");
}
}
// Return the JSX element as usual
return (
<form onSubmit={handleSubmit}>
{inputs.map(renderInput).map((input) => (
<div key={input.props.name}>
{input}
<input
type='text'
name={input.props.name}
value={inputs.find((i) => i.name === input.props.name)?.value || ""}
onChange={handleChange}
/>
</div>
))}
<button type='submit'>Submit</button>
</form>
);
}Then, you create concrete components that inherit from the abstract component and implement the abstract methods:
// LoginForm.tsx
import React from "react";
import { AbstractForm } from "./AbstractForm";
type Input = {
name: string;
value: string;
};
// Define the login form component as a functional component
export function LoginForm() {
// Define the helper functions as regular functions
function getInitialInputs(): Input[] {
return [
{ name: "username", value: "" },
{ name: "password", value: "" },
];
}
function renderInput(input: Input): JSX.Element {
return <label htmlFor={input.name}>{input.name}</label>;
}
function validateInputs(inputs: Input[]): boolean {
const username = inputs.find((input) => input.name === "username")?.value;
const password = inputs.find((input) => input.name === "password")?.value;
return username && password && username.length >= 3 && password.length >= 6;
}
function submitForm(inputs: Input[]): void {
const username = inputs.find((input) => input.name === "username")?.value;
const password = inputs.find((input) => input.name === "password")?.value;
console.log(
`Logging in with username: ${username} and password: ${password}`
);
// You can use any login API here
}
// Return the AbstractForm component with the helper functions as props
return (
<AbstractForm
getInitialInputs={getInitialInputs}
renderInput={renderInput}
validateInputs={validateInputs}
submitForm={submitForm}
/>
);
}// LoginForm.tsx
import React from "react";
import { AbstractForm } from "./AbstractForm";
type Input = {
name: string;
value: string;
};
// Define the login form component as a functional component
export function LoginForm() {
// Define the helper functions as regular functions
function getInitialInputs(): Input[] {
return [
{ name: "username", value: "" },
{ name: "password", value: "" },
];
}
function renderInput(input: Input): JSX.Element {
return <label htmlFor={input.name}>{input.name}</label>;
}
function validateInputs(inputs: Input[]): boolean {
const username = inputs.find((input) => input.name === "username")?.value;
const password = inputs.find((input) => input.name === "password")?.value;
return username && password && username.length >= 3 && password.length >= 6;
}
function submitForm(inputs: Input[]): void {
const username = inputs.find((input) => input.name === "username")?.value;
const password = inputs.find((input) => input.name === "password")?.value;
console.log(
`Logging in with username: ${username} and password: ${password}`
);
// You can use any login API here
}
// Return the AbstractForm component with the helper functions as props
return (
<AbstractForm
getInitialInputs={getInitialInputs}
renderInput={renderInput}
validateInputs={validateInputs}
submitForm={submitForm}
/>
);
}Advantages
- Promotes code reusability and maintainability by encapsulating algorithms or strategies into separate classes.
- Enables the dynamic selection of algorithms at runtime, providing flexibility and extensibility.
- Facilitates the open/closed principle by allowing the addition of new strategies without modifying existing code.
- Enhances testability and modularity by isolating algorithmic variations from the client code.
Disadvantages
- Can introduce additional complexity when managing multiple strategies and their configurations.
- Requires proper selection and management of strategies, as choosing the wrong Template method may impact performance or produce incorrect results.
- May result in a larger codebase if there are numerous strategies or complex algorithm variations.