Skip to main content

Storybook and HOCs a Beautiful Friendship ๐Ÿค

ยท 6 min read
Lori Hutchek
Lori Hutchek

A little historyโ€‹

When first building out our website we did like many people do and directly accessed our hooks within components. There's nothing wrong with that but as the site grew, as our components file sizes grew, we realized we were running into a few walls. How can we make our components easier to explore and at the same time make our components smaller with the goal of better re-usability?

Several of us have used Storybook at previous companies, so selecting Storybook felt like a no-brainer. Yes, there are storybook competitors out there but it is still the best component viewer out there. You can learn more about Storybook's robust features here at Why Storybook. Now the question was how we make our components more reusable and separate our business logic from our layout logic.

As an engineering team, we were already using custom hooks. If you haven't used them before it is a powerful upgrade provided by hooks which allows you to organize your hooks logic into a separate function to make it more easily reusable.

Take this component example.

// todo-list.tsx
import { useEffect, useState } from 'react';
import { List, ListItem, ListItemText } from '@mui/material';

export interface Todo {
id: number;
text: string;
completed: boolean;
}

export const TodoList = (): JSX.Element => {
const [todos, setTodos] = useState<Todo[]>([]);

useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos')
.then((response) => response.json())
.then((todos: Todo[]) => setTodos(todos));
}, []);

return (
<>
<List>
{todos.map((todo) => (
<ListItem key={todo.id}>
<ListItemText
primary={todo.text}
secondary={todo.completed ? 'Completed' : 'Not Completed'}
/>
</ListItem>
))}
</List>
</>
);
};

There's nothing wrong with this approach. It's the most commonly shown example of how to set up a component with hooks. But what if you have to call the to-do list query from multiple components? Or do you need to massage the data returned from the API for your components? Here's where custom hooks come in handy. We can move our fetch of data into a separate file containing our hook, which helps make our component cleaner and easier to reason through.

import { useEffect, useState } from 'react';
import type { Todo } from '../components/todo-list';

interface UseTodoReturn {
todos: Todo[];
}

export function useTodos(): UseTodoReturn {
const [todos, setTodos] = useState<Todo[]>([]);

useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos')
.then((response) => response.json())
.then((todos: Todo[]) => setTodos(todos));
}, []);

return { todos };
}
// todo-list.tsx
import { List, ListItem, ListItemText } from '@mui/material';
import { useTodos } from '../queries/todo-list-query';

export interface Todo {
id: number;
text: string;
completed: boolean;
}

export const TodoList = (): JSX.Element => {
const { todos } = useTodos();

return (
<List>
{todos.map((todo: Todo) => (
<ListItem key={todo.id}>
<ListItemText
primary={todo.text}
secondary={todo.completed ? 'Completed' : 'Not Completed'}
/>
</ListItem>
))}
</List>
);
};

Now that you have this tidier component. It's time to build your stories!

Tidier

But what about our data?โ€‹

One of the powerful features of Storybook is its add-ons! Allowing your component to fetch data from your service (or an external one) can be complicated once you take it out of your application. Instead of calling your API from your Storybook application; ideally, we are mocking requests. Mock API Request is a great place to look at mocking data requests for your components. However, we decided to take an alternate route.

In walks HOCsโ€‹

Walk in

To quote ChatGPT

A Higher-Order Component (HOC) is a design pattern used in React, a popular JavaScript library for building user interfaces. It is not a feature of React itself but rather a pattern that leverages React's composability to enhance component functionality and reusability. HOCs are functions that take one component and return another component with additional props or behavior. They are a powerful tool for code reuse, logic abstraction, and separation of concerns in React applications.

Taking advantage of this design pattern makes for a powerful coupling. Yes, we pulled our fetch for to-do's out into a custom hook. But now we can update our component's props or compose several fetches together isolated to a HOC in our application. Our component now becomes a pure UI component. Allowing it to focus on the work needed to display our component's various use cases.

In your component that composes your page together you'll want to now wrap your todo list component with your new HOC.

// page.tsx
import { WithTodos } from '../queries/with-todos';
import { TodoList } from '../components/todo-list';

const TodoListWithData = WithTodos(TodoList);

export const Page = (): JSX.Element => (
<>
<TodoListWithData />
</>
);

Your HOC now has your custom hook call.

// with-todos.tsx
import React from 'react';
import { useTodos } from '../queries/todo-list';

type HOCComponentProps<TProps> = Omit<TProps, 'todos'>;

export function WithTodos<TProps>(
Component: React.ComponentType<TProps>,
): (props: HOCComponentProps<TProps>) => JSX.Element {
const ComponentRender = (props: TProps): JSX.Element => {
const { todos } = useTodos();

return <Component {...props} todos={todos} />;
};
ComponentRender.displayName = `WithTodos(${Component.displayName})`;

return ComponentRender;
}

And finally your component now expects the todos as a prop.

// todo-list.tsx
import { List, ListItem, ListItemText } from '@mui/material';

export interface Todo {
id: number;
text: string;
completed: boolean;
}

export interface TodoListProps {
todos: Todo[];
}

export const TodoList = ({ todos }: TodoListProps): JSX.Element => (
<List>
{todos.map((todo: Todo) => (
<ListItem key={todo.id}>
<ListItemText
primary={todo.text}
secondary={todo.completed ? 'Completed' : 'Not Completed'}
/>
</ListItem>
))}
</List>
);

But what about Typescript types!?!โ€‹

If you are trying this out as you read this, you may have run into an issue with typescript complaining that your root TodoListWithData component is missing props. The issue is that typescript is expecting those props from the top of the component tree. So we need to tell typescript that our HOC will handle adding the required props for your component.

Here is how we do this:

type HOCComponentProps<TProps> = Omit<TProps, 'todos'>;

That's a wrap!โ€‹

Hooray

Using HOCs allows us to separate our business logic easily from our UI. Which in turn, helps us to build pure components to show in our Storybook stories. Read our article on MUI which also discusses how we use Storybook to share our coded design schema with our designers.

Learn moreโ€‹