Documenting React Hooks With Storybook

Josh Farrant

Storybook has become an indispensable tool for building and documenting UI components, and the Controls addon (née Knobs) is an easy way to allow users to interact and experiment with those components.

With a bit of creativity, we can use those same tools to allow users to experiment and interact with our React Hooks too.

A simple example

Let's say we have a custom Hook called useRandomId which wraps a generateRandomId function, guaranteeing that the ID will remain the same across renders.

import { useState } from 'react';
import { generateRandomId } from './utils';

export function useRandomId(length: number): string {
  const [id] = useState(() => generateRandomId(length));
  return id;
}

💡 This Hook is really useful for generating IDs for inputs and labels, as it will never change during a component's lifecycle!

What might we want to document with our custom Hook?

Well it would be useful to see how the generated ID changes when we pass different length values to the Hook. It could also be nice to show that the ID remains the same even across renders.

Let's have a look at what the story for this Hook might look like.

import React from 'react';
import { Meta, Story } from '@storybook/react';
import { useRandomId } from './use-random-id.hook';

type DemoProps = {
  length: number;
};

const Demo = ({ length }: DemoProps) => {
  const id = useRandomId(length);

  return (
    <pre>
      <code>id: {id}</code>
    </pre>
  );
};

const meta: Meta = {
  title: 'useRandomId',
  component: Demo,
  argTypes: {
    length: {
      control: {
        type: 'number',
      },
      defaultValue: 10,
    },
  },
  parameters: {
    controls: { expanded: true },
  },
};

export default meta;

const Template: Story<DemoProps> = args => (
  <Demo {...args} />
);
export const Default = Template.bind({});
Default.args = {};

The stories for our custom Hook with the length control set to 10. A 10 character ID is displayed.

If you give this a try yourself you'll set that it sort of works. The random ID is displayed correctly, but if we change the value in the length control our ID doesn't change.

The stories for our custom Hook with the length control set to 4. A 10 character ID is displayed.

If we think back to the implementation of our useRandomId Hook this actually makes sense. The ID is set once and then never changes, no matter how many times the component rerenders. To see the length of our ID update we need to remount our Hook so a new ID is generated.

Storybook doesn't provide a way for us to force our story to remount, so instead we'll have to build our own wrapper component to handle remounting and rerendering the provided children.

const RenderingControls = ({
  children,
}: PropsWithChildren<Record<string, unknown>>) => {
  const [key, setKey] = useState(1);
  const [_, setRerender] = useState(1);

  return (
    <div key={key}>
      {children}
      <hr />
      <div>
        <button onClick={() => setRerender(x => x + 1)}>
          Rerender Hook
        </button>
        <button onClick={() => setKey(x => x + 1)}>
          Remount Hook
        </button>
      </div>
    </div>
  );
};

const Demo = ({ length }: DemoProps) => {
  const id = useRandomId(length);

  return (
    <pre>
      <code>id: {id}</code>
    </pre>
  );
};

const DemoWithControls = (props: DemoProps) => (
  <RenderingControls>
    <Demo {...props} />
  </RenderingControls>
);

The RenderingControls component takes advantage of a couple of tricks to allow us to force our component to remount and rerender.

To force a remount, we wrap our component in a div with a key prop. When we want to force a remount, we can just increment this key prop, which we do here with the useState Hook.

Similarly, when we want to force a rerender we just have to update a piece of state in the component. It doesn't matter that we don't actually use the state (named with an underscore) anywhere, just updating the value will trigger a rerender.

Let's give our new RenderingControls a try!

The stories for our custom Hook with the length control set to 10. A 10 character ID is displayed along with two buttons, labelled Rerender Hook and Remount Hook respectively.

If we reduce the value of the length control down to 4 again and hit the Remount Hook button we'll see that a 4 character ID gets generated! 🎉

The stories for our custom Hook with the length control set to 4. A 4 character ID is displayed along with two buttons, labelled Rerender Hook and Remount Hook respectively.

Putting this all together, the complete source code for our story is as follows.

import React, { PropsWithChildren, useState } from 'react';
import { Meta, Story } from '@storybook/react';
import { useRandomId } from './use-random-id.hook';

type DemoProps = {
  length: number;
};

const RenderingControls = ({
  children,
}: PropsWithChildren<Record<string, unknown>>) => {
  const [key, setKey] = useState(1);
  const [_, setRerender] = useState(1);

  return (
    <div key={key}>
      {children}
      <hr />
      <div>
        <button onClick={() => setRerender(x => x + 1)}>
          Rerender Hook
        </button>
        <button onClick={() => setKey(x => x + 1)}>
          Remount Hook
        </button>
      </div>
    </div>
  );
};

const Demo = ({ length }: DemoProps) => {
  const id = useRandomId(length);

  return (
    <pre>
      <code>id: {id}</code>
    </pre>
  );
};

const DemoWithControls = (props: DemoProps) => (
  <RenderingControls>
    <Demo {...props} />
  </RenderingControls>
);

const meta: Meta = {
  title: 'useRandomId',
  component: DemoWithControls,
  argTypes: {
    length: {
      control: {
        type: 'number',
      },
      defaultValue: 10,
    },
  },
  parameters: {
    controls: { expanded: true },
  },
};

export default meta;

const Template: Story<DemoProps> = args => (
  <DemoWithControls {...args} />
);
export const Default = Template.bind({});
Default.args = {};

A more complex example

Our example Hook, useRandomId, is a relatively simple one, however the same technique works in just the same way with more complex Hooks. Below are the stories for a usePagination Hook.

The stories for a use pagination Hook total pages control set to 10, the initial page control set to 1, and the page numbers count control set to 5. A JSON stringified object is displayed showing the outputted data from the Hook in a readable format.

import React, { PropsWithChildren, useState } from 'react';
import { Meta, Story } from '@storybook/react';
import { usePagination } from '.';

type DemoProps = {
  totalPages: number;
  initialPage: number;
  pageNumbersCount: number;
};

const RenderingControls = ({
  children,
}: PropsWithChildren<Record<string, unknown>>) => {
  const [key, setKey] = useState(1);
  const [_, setRerender] = useState(1);

  return (
    <div key={key}>
      {children}
      <hr />
      <div>
        <button onClick={() => setRerender(x => x + 1)}>
          Rerender Hook
        </button>
        <button onClick={() => setKey(x => x + 1)}>
          Remount Hook
        </button>
      </div>
    </div>
  );
};

const Demo = ({
  totalPages,
  initialPage,
  pageNumbersCount,
}: DemoProps) => {
  const data = usePagination({
    totalPages,
    initialPage,
    pageNumbersCount,
  });

  return (
    <>
      <pre>
        <code>{JSON.stringify(data, null, 4)}</code>
      </pre>
      <button onClick={() => data.setPreviousPageActive()}>
        setPreviousPageActive
      </button>
      <button onClick={() => data.setNextPageActive()}>
        setNextPageActive
      </button>
    </>
  );
};

const DemoWithControls = (props: DemoProps) => (
  <RenderingControls>
    <Demo {...props} />
  </RenderingControls>
);

const meta: Meta = {
  title: 'usePagination',
  component: DemoWithControls,
  argTypes: {
    totalPages: {
      control: {
        type: 'number',
      },
      defaultValue: 10,
    },
    initialPage: {
      control: {
        type: 'number',
      },
      defaultValue: 1,
    },
    pageNumbersCount: {
      control: {
        type: 'number',
      },
      defaultValue: 5,
    },
  },
  parameters: {
    controls: { expanded: true },
  },
};

export default meta;

const Template: Story<DemoProps> = args => (
  <DemoWithControls {...args} />
);
export const Default = Template.bind({});
Default.args = {};

The key differences to note with this example are the additional controls specified in the meta constant, and the fact that we're stringifying the object outputted from our Hook and displaying that entire object.

You can also see that the RenderingControls component is identical in both this and the useRandomId example and so could easily be abstracted out into its own component.

Summary

Rendering the output of React Hooks in Storybook is surprisingly straightforward and just requires a bit of creative thinking. The approach shown above is a basic example of how this can be achieved but only really scratches the surface of what's possible.

If you're doing something interesting with Hooks inside Storybook then please do let me know!