Increase the reusability of libraries with the strategy pattern
One of the central problems of software development is finding a division between code that changes often and code that changes rarely. Design Patterns offer solution templates for these problems, that we can apply in our own projects, even in web development. In this article, I want to showcase the strategy pattern, that allows you to create more flexible and extensible libraries. But don't worry, we will not be copying the traditional class-based approach into our TypeScript code base.
Design Patterns in frontend development
Long over are the times where web development was simply about adding the correct rules to the CSS-Classes that have been rendered by the backend. Nowadays, we build complex, full-featured applications, that happen to run in a browser. That means that we, as frontend developers, have to learn about approaches that have been common practice in backend development for a long time. One of these approaches is the application of Design Patterns. But don't worry, we won't be replicating the UML-diagram of Java classes in TypeScript and call it a day. Rather, we will take a closer look at the intent behind one of the patterns, and adapt the implementation to the pracices and approaches of web development with TypeScript.
First example: Displaying users
Have you ever had the problem that you wanted your code to be flexible for different use cases and your initial solution was to introduce branches?
Let's start with an example to be more concrete. We start with the following use case: You are building a site where users can connect to other users and call them their friends. Now, you have a UI where users can hover over the names of other users and see a list of their mutual friends. To display the data, you might implement something like this:
type User = {
name: string;
mail: string;
isOnline: boolean;
};
function displayUsersTooltip(users: User[]) {
const friends: string[] = [];
for (const user of users) {
/**
* We implement the decision of selecting which fields should be displayed
* directly in this function.
*/
friends.push(user.name);
}
showTooltip(friends);
}
// Later
const users = await loadMutualFriends();
displayUsersTooltip(users);
Perfect, we now have a simple function that can be used to display a tooltip that contains a list of users and everyone is happy.
The next requirement
The next day, your project manager is happy that you implemented the tooltip for mutual friends so quickly. They really like this new tooltip and want to reuse this for another case. This time, you are tasked to add this tooltip to all conversations on the platform. The tooltip should display all users that are a part of the respective conversation. However, it should not only display their names, but also their email adresses. The tooltip for mutual friends should stay the same in the UI but of course, we want to reuse as much code as possible to meet the project deadline.
Fine, you think. This is just another case that I can add to my tooltip function, and you might implement something like this:
function displayUsersTooltip(users: User[], includeMail: boolean) {
const friends: string[] = [];
for (const user of users) {
let friend = "";
if (includeMail) {
friend = `${user.name} (${user.mail})`;
} else {
friend = user.name;
}
friends.push(user.name);
}
showTooltip(friends);
}
Your function is now a bit larger, but it can now handle both use cases. However, you might feel a little uneasy about the solution, because you know your project manager. What if they need a tooltip with only emails? What if they need a tooltip with the online status of users? The possibilities are almost endless, but our implementation is very much not.
Your way out: The strategy pattern
This use case is a perfect opportunity for the strategy pattern to shine. Its purpose is to stop implementing a single algorithm directly but instead to allow us to select a specific algorithm at runtime, that can be used in our generic function. Additionally this allows us to divide our code into two separate sections: library code, that does not know about our specific use case, and application code that knows exactly in which use case we currently are.
I will spare you the original class diagram of the pattern, as it is not modeled for programming languages with first class functions in mind. Let's jump straight into the implementation of the strategy pattern in TypeScript.
First, we need to think about what exactly is the part of our algorithm, that we want to be defined separately for each use case. For our tooltip, the generic library part is to display a tooltip for a list of strings. Additionally, we want to define that each of the strings represents exactly one user. That means, the only part that needs to be flexible is how we generate a display string for each user. And this sub-algorithm is precisely the responsibility of our implemented strategies. We can also define a type for this strategy as a TypeScript type and add several implementations for our use cases:
type UserDisplayStrategy = (user: User) => string;
/**
* The first strategy generates strings with the users name,
* based on a user object.
*/
const userNameOnlyStrategy: UserDisplayStrategy = (user) => user.name;
/**
* The second strategy generates strings with the users name and email,
* based on a user object.
*/
const userNameAndEmailStrategy: UserDisplayStrategy = (user) =>
`${user.name} (${user.mail})`;
Next, since we can pass functions as arguments to other functions, we can adapt our tooltip function so that it receives not a boolean, but a UserDisplayStrategy
.
function displayUsersTooltip(
users: User[],
userDisplayStrategy: UserDisplayStrategy
) {
const friends: string[] = [];
for (const user of users) {
/**
* We generate the display string from a user with the given
* userDisplayStrategy.
*
* Now, this code does not make the decision of selecting
* the fields to display in the tooltip.
*
* This decision is now pushed outwards to the application code.
*/
const displayedString = userDisplayStrategy(user);
friends.push(displayedString);
}
showTooltip(friends);
}
/**
* Now, in our application code,
* we call the library and pass in the correct strategy for
* the use case.
*/
// For mutual friends:
const users = await loadMutualFriends();
displayUsersTooltip(users, userNameOnlyStrategy);
// For conversation participants:
displayUsersTooltip(users, userNameAndEmailStrategy);
Extracting the strategies into separate variables is only really beneficial, when the strategy itself is complex and used in multiple occasions. For simple cases, I prefer writing the strategy inline:
// For mutual friends:
const users = await loadMutualFriends();
displayUsersTooltip(users, (user) => user.name);
// For conversation participants:
displayUsersTooltip(users, (user) => `${user.name} (${user.mail})`);
This only marginally increases the size of these lines of code but reduces indirection, since we do not have to jump to the definition of the strategies to know what will happen.
A familiar approach with an unfamiliar name.
The previous code examples might look familiar to you. This is because this pattern is used a lot in JavaScript. All array functions for example are using these strategies (or callbacks, as they are more commonly called in the web world). Let's take a look at a couple of examples:
Sorting in JavaScript
const users = await loadMutualFriends();
const sortedByName = users.sort((a, b) => a.name.localeCompare(b.name));
const sortedByOnlineState = users.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (b.isOnline && !a.isOnline) return 1;
return 0;
});
The main complexity of a sorting algorithm is in efficiently selecting elements to compare, to recieve a fully sorted list. The "easy" part, is to compare two elements. However, the built-in library algorithm can not make this "easy" decision for us, we have to provide a function (a strategy) that can make this decision on the application code level. The hard part of efficiently selecting elements to compare, is taken care of by our runtime (e.g. the browser).
Render props in React
In addition to regular JavaScript code that often uses this pattern, we may encounter it in React code as well. Let's take a look at this snippet from the official React Router docs:
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";
ReactDOM.render(
<Router>
<Route path="/home" render={() => <div>Home</div>} />
</Router>,
node
);
Here, the library handles the complex part of monitoring the URL to check if we currently are on the /home
path. Again, the library can only make some of the decisions for us. It has to give us the freedom of selecting what we want to render when the given URL matches. For this, we may provide our own PageRenderingStrategy
, or as it is called commonly in React: A render prop. As with the example earlier, we also have the separation of library level code (react-router-dom) and application code, where only the application code can know about the specific use cases to decide what we want to render.
The implementation of the Route
component might look a little something like the following snippet. (Yes, I know, the real implementation uses a Class-Component and no hooks and has more props that can be passed in, but this does not matter for the example.)
import { useRouteMatch, RouteMatch } from "./some-internal-module";
export function Route(props: {
path: string;
render: (match: RouteMatch) => ReactNode;
}) {
const match = useRouteMatch(props);
/**
* If route does not match the current URL, do not render anything.
*/
if (!match) return null;
/**
* If route matches current URL, render what the application code
* wants.
*/
return <>{props.render(match)}</>;
}
As you can see, the strategy pattern (or callback functions, or render props) allows us to write library code, that is flexible and extensible. We can easily pass in more complex strategies without touching the library and we could still define and export "default strategies" to be used without much overhead:
export function byLowerCaseField<T>(
getStringFieldStrategy: (item: T) => string
) {
return function comparator(a: T, b: T) {
const stringA = getStringFieldStrategy(a).toLowerCase();
const stringB = getStringFieldStrategy(b).toLowerCase();
return stringA.localeCompare(stringB);
};
}
/**
* Somewhere in application code:
*/
const users = await loadMutualFriends();
const sortedByName = users.sort(byLowerCaseField((u) => u.name));
A few words on naming
As we already briefly mentioned before, naming these "strategies" is hard. In the wild, you will rarely see a function named xyzStrategy
. In our projects, we normally try to find names that better describe the purpose of this function. For our example earlier, I would have named the argument something like userToDisplayString
or displayStringFromUser
. The same goes for the Array.prototype.sort-function, whose argument is called compareFunction
on MDN and not comparisonStrategy
and for the React-Router exmple, where the prop is simply called render
, as this is exactly what it does.
Conclusion
I hope these examples could showcase the potential benefits of applying the strategy pattern in your own projects. By using it, we can divide our code in a library and an application level, while keeping the library flexible. It allows us to be specific about decisions that we want to handle within the library and those that we want to push outwards into the application.