August 7, 2019

Sharing React Hooks with Storybook


I’ve been excited about React Hooks ever since I saw Dan Abramov’s intro talk last fall. I’ve been playing around with hooks in my side projects for a few months now, and I LOVE them. Reducing the clutter of JS classes? Great! Eliminating bugs from missed lifecycle hooks? Awesome! Moving to a render-first (rather than a life-cycle-first) mindset? Super duper great! Encapsulating stateful logic? THE BEST!

But, as with many technologies, there’s a huge difference between using them alone and using them in a group:

Most of the blog posts I’ve seen haven’t addressed how to use hooks in a shared environment, which comes with its own own challenge:

How will other devs know how to use an existing custom hook?

There are many different ways to approach these issues. Testing, well-organized file structures, using a typed language like TypeScript, and documentation are all solid ways to address the issue, and should be used! Today, though, I’m going to focus on the one I’ve been loving lately. (Note: the rest of this post assumes you are somewhat familiar with hooks - if you haven’t used them before, check out the docs and then come back - here we’ll discuss useState and custom hooks)

Storybook is a tool for developing UI components in isolation from your application. It’s great for being able to develop a dumb component without needing to refresh an entire app on each iteration loop. However, when I was working on some custom hooks, I realized that Storybook is also awesome at demonstrating custom hooks’ behavior in a way that’s discoverable and easy to use.

To show what I mean, I’ve set up a Storybook site. If you’d like to just see the code, go check it out. Otherwise, keep reading to hear more!

Let’s use an example custom hook, useValidatedInput. It encapsulates the logic for extracting text from an input, running some customized validation on the text, and returning an error:

import React, {useState} from 'react';

export default function useValidatedInput(initialText, validator) {
  const [text, setText] = useState(initialText);
  const [error, setError] = useState(validator(initialText));

  const onChange = (e) => {
    const newText = e.target.value;
    const error = validator(newText);
    setError(error);
    setText(newText);
  }

  return [{value: text, onChange}, error];
}

This hook is pretty simple, but what if we had one more complicated? It’s not immediately obvious what this hook returns, or how to use its return value. It’s not clear what form validator should take. (Again, TypeScript helps a lot with this, but if you’re not using TS I think the problem still stands). Additionally, hooks are still pretty new, so I can reasonably assume that most of the devs who see this code won’t be familiar with how hooks work in general.

Usually, my first instinct for how to document usage is through tests. So, let’s say I have a shared React components folder, with a custom hooks folder. I put useValidatedInput into that folder, and add a test. From the React docs, here’s how to test a custom hook:

If you need to test a custom Hook, you can do so by creating a component in your test, and using your Hook from it. Then you can test the component you wrote. Source

Ok, let’s give it a try:

import {expect} from 'chai';
import {shallow} from 'enzyme';

import useValidatedInput from '../useValidatedInput';

describe('useValidatedInput', () => {
  // Make a dummy component that uses the hook
  function DemoComponent() {
    const validator = text => (text.length > 5 ? 'Too long' : null);
    const [inputProps, error] = useValidatedInput('four', validator);

    return (
      <>
        <input {...inputProps} />
        {error && <div className="error">{error}</div>}
      </>
    );
  }

  it('shows the initial value', () => {
    const wrapper = shallow(<DemoComponent />);
    expect(wrapper.find('input')).prop('value', 'four');
  });

  it('shows an error if the text is too long', () => {
    const wrapper = shallow(<DemoComponent />);
    expect(wrapper.find('.error')).not.present();
    expect(wrapper.find('input')).simulate(
      'change', 
      {target: {value: 'Longer than five characters'}}
    );
    expect(wrapper.find('.error')).text('Too long');
  });
});

This is… fine, but I don’t love that testing useValidatedInput correctly depends on writing DemoComponent correctly, and it’s weird to me that reading the test feels more like you’re reading that the hook was used correctly than that the hook works as intended. Maybe it’s just unfamiliarity, but I don’t find this to be a very intuitive way of understanding a hook’s behavior. How can we:

  1. Give a quick and interactive demonstration of our hook’s functionality?
  2. Make it easy for other devs to discover this hook when they need it?

Enter Storybook

Let’s create a story for our custom hook:

import React from 'react';
import { storiesOf } from '@storybook/react';

import useValidatedInput from '../customHooks/useValidatedInput';

function DemoComponent({ initialText }) {
  const validator = text => (text.length > 5 ? 'Too long' : null);
  const [inputProps, error] = useValidatedInput(initialText, validator);
  return (
    <div>
      <input {...inputProps} />
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
}


storiesOf('CustomHooks|useValidatedInput', module)
  .add('Default', () => <DemoComponent initialText="Hi" />)
  .add('With error', () => <DemoComponent initialText="Hello World" />);

If you’re not familiar with storybook, this is saying that we have a category, ‘CustomHooks’, with a subcategory, ‘useValidatedInput’, for which we want to show two states: ‘Default’ and ‘With error’. We pass into each “story” a component that initially renders with the described state, which ends up looking like this:

Storybook gif

Sweet! Now, assuming you have Storybook set up as part of your team’s workflow, they’ll be able to quickly browse the custom hooks available to them and interact with a component that uses the hook!

Hmm… we’ve definitely got an opportunity for some refactoring here: in order to render our story, we made a demo component that looks almost exactly like the demo component in our test! We could keep them separate, or we could consolidate them together in the custom hook file itself:

// useValidatedInput.jsx
import React, { useState } from 'react';

export default function useValidatedInput(initialText, validator) {
  const [text, setText] = useState(initialText);
  const [error, setError] = useState(validator(initialText));

  const onChange = e => {
    const newText = e.target.value;
    const error = validator(newText);
    setError(error);
    setText(newText);
  };

  return [{ value: text, onChange }, error];
}

export function DemoComponent({ initialText }) {
  const validator = text => (text.length > 5 ? 'Too long' : null);
  const [inputProps, error] = useValidatedInput(initialText, validator);

  return (
    <div>
      <input {...inputProps} />
      {error && <div class="error" style={{ color: 'red' }}>{error}</div>}
    </div>
  );
}

// useValidatedInput-test.jsx
import {expect} from 'chai';
import {shallow} from 'enzyme';

import {DemoComponent} from '../useValidatedInput';

describe('useValidatedInput', () => {
  it('shows the initial value', () => {
    const wrapper = shallow(<DemoComponent />);
    expect(wrapper.find('input')).prop('value', 'four');
  });

  it('shows an error if the text is too long', () => {
    const wrapper = shallow(<DemoComponent />);
    expect(wrapper.find('.error')).not.present();
    expect(wrapper.find('input')).simulate(
      'change', 
      {target: {value: 'Longer than five characters'}}
    );
    expect(wrapper.find('.error')).text('Too long');
  });
});

// useValidatedInput.stories.jsx
import React from 'react';
import { storiesOf } from '@storybook/react';

import { DemoComponent } from '../customHooks/useValidatedInput';

storiesOf('CustomHooks|useValidatedInput', module)
  .add('Default', () => <DemoComponent initialText="Hi" />)
  .add('With error', () => <DemoComponent initialText="Hello World" />);

Our DemoComponent should live with useValidatedInput because they change together; if we ever changed the behavior of useValidatedInput, we’d want to change DemoComponent to demonstrate that new behavior. Colocating them means our tests can use it, our story can use it, and developers who want to know how it works have a working example right in front of them!

I’m a big fan of this pattern and plan to use it in my projects going forward.