bits&pieces by pekala

Discovering Patterns with React Hooks

Published on May 13th, 2019 - originally @ PonyFoo
~ 14 min read

One of the things I enjoy the most about coding is discovering patterns. Being aware and mindful of the patterns emerging in your codebase can make it easier to keep that codebase consistent, readable and easy to navigate. Most patterns will remain unspoken, and some, that prove notably elegant, are named and promoted as best practices. Others might even be candidates for a basis of a new abstraction, although I recommend restraint as it’s worth remembering that abstractions are expensive.

If you’re not familiar with the topic or React hooks, there is plenty of introductory as well as in-depth materials out there. I can also shamelessly recommend my talk on hooks from a recent React Copenhagen meetup. What follows assume at least some understanding of what hooks are and how to use them.

Pattern extinction level event

Discovering new patterns is relatively rare when working with established technologies, but every now and then a new idea comes up that stirs things up within some ecosystem. Enter React hooks. Hooks enable a drastically different way to build stateful components in React, which means that they also invalidate 1 a lot of the established patterns. However, being an exceptionally well thought-through and composition-friendly abstraction, hooks enabled new, elegant patterns to emerge.

Over the last few months, I’ve been building apps with hooks and I’ve been having a lot of fun discovering these patterns. I’d like to share one, that I’ve grown especially fond of. For the lack of a better name, and for sole the purpose of this blog post, let’s call it the Choco🍫 pattern (from Context, HOok, COpomonent). The pattern comes useful when you need a UI widget which is top-level, centralised and unique across the application and needs to expose some functionality to the rest of the components in the app. It might sound vague at the moment, so let’s try to go through solving a concrete problem where I noticed the Choco pattern manifest itself.

Exhibit A: Feature toggles

Let’s try to build a simple feature toggle functionality for a React app. There should be UI where the user can toggle features on and off and we want to expose these settings to any component interested. We can identify parts we will need:

  1. context provider exposing the feature toggles anywhere in the component tree,
  2. component rendering the UI for toggling features,
  3. a place to store the feature toggle state and the associated logic.

Our first attempt could look like this:

import React from "react";
import ReactDOM from "react-dom";
// 1. Context used to expose the features and toggle function
const FeatureToggleContext = React.createContext({
features: {},
onToggleFeature: () => {},
});
// 2. Component rendering the feature toggles as checkboxes
function FeatureToggler() {
const { features, onToggleFeature } = React.useContext(FeatureToggleContext);
const featureNames = Object.keys(features);
return (
<details>
<summary>Features</summary>
{featureNames.map((feature) => (
<label>
<input
type="checkbox"
key={feature}
checked={!!features[feature]}
onChange={() => onToggleFeature(feature)}
/>
{feature}
</label>
))}
{!featureNames.length && "No features to toggle."}
</details>
);
}
const initialFeatures = {
pizzas: false,
};
function App() {
// 3. state storing the feature toggle state and logic to toggle it
const [features, onSetFeatures] = React.useState(initialFeatures);
const onToggleFeature = (feature) =>
onSetFeatures({ ...features, [feature]: !features[feature] });
return (
<FeatureToggleContext.Provider value={{ features, onToggleFeature }}>
<div>
<h1>Food app with feature toggles</h1>
<Food />
<FeatureToggler />
</div>
</FeatureToggleContext.Provider>
);
}
function Food() {
const { features } = React.useContext(FeatureToggleContext);
return features.pizzas ? (
<div>Here, have some tasty 🍕 🍕 🍕</div>
) : (
<div>Here, have some tasty 🍔 🍔 🍔</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

This code is fairly simple and works so we could stop here. However, there is a big opportunity for refactoring, by moving all the code related to the “feature toggle concern” to a separate module, instead of mixing it with the rest of our app. Let’s do just that.

import React from "react";
// 1. Context used to expose the features and toggle function
export const FeatureToggleContext = React.createContext({
features: {},
onToggleFeature: () => {},
});
// 3. state storing the feature toggle state and logic to toggle it
export function useFeatureToggleContextValue(initialFeatures) {
const [features, onSetFeatures] = React.useState(initialFeatures);
const onToggleFeature = (feature) =>
onSetFeatures({ ...features, [feature]: !features[feature] });
return { features, onToggleFeature };
}
// 2. Component rendering the feature toggles as checkboxes
export function FeatureToggler() {
const { features, onToggleFeature } = React.useContext(FeatureToggleContext);
const featureNames = Object.keys(features);
return (
<details>
<summary>Features</summary>
{featureNames.map((feature) => (
<label>
<input
type="checkbox"
key={feature}
checked={!!features[feature]}
onChange={() => onToggleFeature(feature)}
/>
{feature}
</label>
))}
{!featureNames.length && "No features to toggle."}
</details>
);
}

The biggest change here is extracting the state and its related logic into a custom hook. We can now clearly see the Choco pattern here, with the Context, HOok, and COpomonent exported from the new module.

I made a choice here to keep the initialFeatures dictionary with the application code instead of moving it to the newly created module. One could argue either way, but by keeping that dictionary out of the feature toggle module and dependency-injecting it, we make that module completely generic and a candidate for possible extraction into a library.

Again, we could easily end the refactoring here. In fact, that’s the level of decoupling I’d recommend for in this scenario (more on that later). We can, however, take it one step further and introduce an abstraction to hide some of the implementation details and reduce the API surface of the module. Here is one way to do this:

import React from "react";
const FeatureToggleContext = React.createContext({
features: {},
onToggleFeature: () => {}
});
export function FeatureToggleProvider({ children, initialFeatures }) {
const [features, onSetFeatures] = React.useState(initialFeatures);
const onToggleFeature = feature =>
onSetFeatures({ ...features, [feature]: !features[feature] });
return (
<FeatureToggleContext.Provider value={{ features, onToggleFeature }}>
{children}
</FeatureToggleContext.Provider>
);
}
export function useFeatures() {
const { features } = React.useContext(FeatureToggleContext);
return features;
}
export function FeatureToggler() {
const { features, onToggleFeature } = React.useContext(FeatureToggleContext);
const featureNames = Object.keys(features);
return (
<details>
<summary>Features</summary>
{featureNames.map(feature => (
<label>
<input
type="checkbox"
key={feature}
checked={!!features[feature]}
onChange={() => onToggleFeature(feature)}
/>
{feature}
</label>
))}
{!featureNames.length && "No features to toggle."}
</details>
);
}

The biggest difference to notice is that the fact we’re using the native React context is now an implementation detail, not exposed to the rest of the app. By adding our own abstraction on top of it we also gained a more fine-grain control over what properties of the context are exposed. In this case, we could expose only the feature toggle dictionary, while keeping the function to toggle them private.

Another thing to notice is that by wrapping the context provider in a component, we had an opportunity to render the FeatureToggler in that component and take that responsibility away from the module’s consumer. This might be a viable option if we decide to e.g. render the toggler in a portal to make it overlay the page after the user presses some specific key combination. However, in this example I chose not to - this way is much more flexible as the consumer can choose where and how to render the toggler.

What’s interesting is that we kept the same type of exports from the feature toggle module (Context, HOok, and COpomonent), but they are consumed in a different way. The hook is now taking care of exposing the features to the components. The context (or more precisely the wrapped context provider) now also encapsulates the state and related logic.

Did we improve this code in comparison to the previous iteration? I think the answer depends on how we plan to use it. If we decided to extract the feature toggle module into a small library, then that last version of the code is most likely a better choice. It would be easier to document and use the API with a smaller exposed surface. Hiding the implementation details might also enable adding more functionality later on, without introducing breaking changes.

However, if we keep it as a module within the codebase the abstraction creates an unnecessary indirection that the next developer would need to unpack to understand. We created a black box that would need to be explored as opposed to the previous version, which while verbose, used the core React APIs most likely already familiar. Since we don’t extract and version the module, breaking changes are not a big deal as we could quickly make necessary adjustments in the places where the module is consumed.

Exhibit B: Toast notifications

It wouldn’t be much of a pattern if it didn’t repeat. To see how it manifests again and again in this kind of problems, let’s look at one more example. We are going to build a simplified toast notification system. We want to be able to ask for a notification anywhere in our component tree and then show it in a global bar. Again, let’s identify what pieces we are going to need:

  1. A context provider exposing a function that can be used to ask for a toast,
  2. A component rendering the toast bar,
  3. A place to store the notifications state and related logic

We already know what to expect so let’s skip the first draft and go directly to the version with a separate module:

import React from "react";
// 1. Context used to expose the function that can be used to ask for a toast
export const ToastContext = React.createContext({
toasts: [],
addToast: () => {},
removeToast: () => {}
});
// 2. Component rendering the toast bar
export function ToastBar() {
const { toasts, removeToast } = React.useContext(ToastContext);
return (
<div role="alert" aria-atomic="true">
{toasts.map(toast => (
<div onClick={() => removeToast(toast.id)} key={toast.id}>
{toast.message}
</div>
))}
</div>
);
}
function toastReducer(toasts, action) {
switch (action.type) {
case "remove":
return toasts.filter(toast => toast.id !== action.id);
case "add":
return [...toasts, action.toast];
default:
throw new Error("Unexpected action in toast reducer");
}
};
// 3. place to store the notifications state and related logic
export function useToastContextValue() {
const [toasts, dispatch] = React.useReducer(toastReducer, []);
const removeToast = id => dispatch({ type: "remove", id });
const addToast = message => {
const id = Math.random()
.toString(36)
.substring(2, 15);
const toast = { id, message };
dispatch({ type: "add", toast });
setTimeout(() => removeToast(id), 3000);
return id;
};
return React.useMemo(() => ({ addToast, removeToast, toasts }), [toasts]);
};

It’s our Choco pattern on full display! And as in the previous example, we can refactor this to make the API cleaner and more controlled. It could look like this:

import React from "react";
const ToastContext = React.createContext({
toasts: [],
addToast: () => {},
removeToast: () => {}
});
export function ToastBar() {
const { toasts, removeToast } = React.useContext(ToastContext);
return (
<div role="alert" aria-atomic="true">
{toasts.map(toast => (
<div onClick={() => removeToast(toast.id)} key={toast.id}>
{toast.message}
</div>
))}
</div>
);
}
export function useToast() {
const { addToast, removeToast } = React.useContext(ToastContext);
return { addToast, removeToast };
}
function toastReducer(toasts, action) {
switch (action.type) {
case "remove":
return toasts.filter(toast => toast.id !== action.id);
case "add":
return [...toasts, action.toast];
default:
throw new Error("Unexpected action in toast reducer");
}
};
export function ToastProvider({ children }) {
const [toasts, dispatch] = React.useReducer(toastReducer, []);
const removeToast = id => dispatch({ type: "remove", id });
const addToast = message => {
const id = Math.random()
.toString(36)
.substring(2, 15);
const toast = { id, message };
dispatch({ type: "add", toast });
setTimeout(() => removeToast(id), 3000);
return id;
};
const contextValue = React.useMemo(
() => ({ addToast, removeToast, toasts }),
[toasts]
);
return (
<ToastContext.Provider value={contextValue}>
{children}
</ToastContext.Provider>
);
}

As in the feature toggle example, I chose to only expose some part of the context value to the component tree through the custom hook. In this case, the array of toasts remains private, while the functions to add and remove toasts are exposed.

Exhibit C: User guide

To cement the pattern further, let’s look at one last example. We will build user guide functionality. We want to add a help button in some places in our app, which when clicked, opens a user guide widget that explains the part of the interface in question. You know the drill by now, let’s see what we’re going to need:

  1. A context provider exposing the help icon button component
  2. A component rendering the user guide UI
  3. A place to store state (i.e. which piece of the interface the user asked for) and related logic

Our first iteration could look like this:

import React from "react";
// 1. Context provider exposing the help icon button component
export const GuideContext = React.createContext({
topicSelected: null,
closeGuide: () => null,
GuideTrigger: () => null,
topics: []
});
// 2. Component rendering the user guide UI
export function Guide() {
const { topicSelected, closeGuide, topics } = React.useContext(GuideContext);
if (!topicSelected) {
return null;
}
return (
<aside>
<h1>User Guide</h1>
<button onClick={closeGuide}>Close</button>
{topics.map(topic => (
<section
id={topic.id}
style={{
background: topic.id === topicSelected ? "yellow" : undefined
}}
>
<h2>{topic.title}</h2>
<p>{topic.description}</p>
</section>
))}
</aside>
);
}
// 3. state storing which piece of the interface the user asked for, and related logic
export const useGuideContextValue = topics => {
const [topicSelected, setTopicSelected] = React.useState(false);
const closeGuide = () => setTopicSelected(null);
const GuideTrigger = ({ topic }) => (
<button
onClick={() => setTopicSelected(topicSelected === topic ? null : topic)}
>
?
</button>
);
return React.useMemo(
() => ({
topicSelected,
GuideTrigger,
closeGuide,
topics
}),
[topicSelected, topics]
);
};

Hopefully, the structure is intuitive and familiar by now. The only significant difference is the type of functionality exposed to the other components. In the case of the guide, we choose to expose a component, while in the previous examples it was a method or state data. Anything goes!

Following the previously established procedure, the refactored version of the same code could look like this:

import React from "react";
const GuideContext = React.createContext({
topicSelected: null,
closeGuide: () => null,
GuideTrigger: () => null,
topics: []
});
export function Guide() {
const { topicSelected, closeGuide, topics } = React.useContext(GuideContext);
if (!topicSelected) {
return null;
}
return (
<aside>
<h1>User Guide</h1>
<button onClick={closeGuide}>Close</button>
{topics.map(topic => (
<section
id={topic.id}
style={{
background: topic.id === topicSelected ? "yellow" : undefined
}}
>
<h2>{topic.title}</h2>
<p>{topic.description}</p>
</section>
))}
</aside>
);
}
export function useGuide() {
const { GuideTrigger } = React.useContext(GuideContext);
return GuideTrigger;
}
export function GuideProvider({ children, topics }) {
const [topicSelected, setTopicSelected] = React.useState(false);
const closeGuide = () => setTopicSelected(null);
const GuideTrigger = ({ topic }) => (
<button
onClick={() => setTopicSelected(topicSelected === topic ? null : topic)}
>
?
</button>
);
const contextValue = React.useMemo(
() => ({
topicSelected,
GuideTrigger,
closeGuide,
topics
}),
[topicSelected, topics]
);
return (
<GuideContext.Provider value={contextValue}>
{children}
</GuideContext.Provider>
);
}

Spot the pattern

Of course, I slightly cheated with the way I presented the refactoring evolution here. I already knew the pattern and made the examples follow it from the very beginning. In fact, when I first got to implement these features using hooks, I didn’t immediately see the Choco pattern and my initial implementation attempts were chaotic and inconsistent. But with time, I started to see the same code paths and elements showing up repeatedly and I was able to streamline and clean up my implementations.

Discovering and embracing patterns can be a powerful technique that can make penetrating and understanding a codebase easier. I’m always on a lookout for concerns and features that share similar requirements and characteristics and try to implement them in a consistent way, without introducing abstractions prematurely.

I’m really eager to find out what other concerns are candidates for using this pattern and what other patterns using hooks will surface. Let me know what you think!

Brought to you by Maciek. Who dat?