React コンポーネントの props をドキュメント化する

React では外から props を渡すことで、一定の見た目や振る舞いを制御しつつ、それらを上書きして使うことができる UI パーツをコンポーネントとしてカプセル化することができる。
React コンポーネントを書くときに、Storybook でカタログを作ることはもはや一般的になってきたように思えるが、それではコンポーネントの扱い方をちゃんとドキュメントとして残せているだろうか。
つまり、「コンポーネントを扱うために必要となる props 情報や、使われることを想定している場面を、コンポーネントを使おうとしている開発者に共有できているか」ということである。

この記事では、「Props 情報をドキュメント化する」ために便利なツールと、それを駆使する術を紹介する。
前提として、TypeScript + React である。

なお、この記事で紹介していることは、このリポジトリで試すことができる。

コンポーネント の props の情報を JSDoc で書く

以下のような presentational component があり、

import * as React from "react";

import styled, { css } from "styled-components";

type Props = React.ComponentPropsWithRef<"div"> & {
  isError?: boolean;
  errorMessage?: string;
  inputProps?: React.ComponentPropsWithRef<"input">;
};

const Presentational = (props: Props) => {
  const { inputProps, isError, errorMessage, ...restProps } = props;

  return (
    <Wrap {...restProps}>
      <Element {...inputProps} isError={isError} />
      {isError && <ErrorMessage>{errorMessage}</ErrorMessage>}
    </Wrap>
  );
};

export const Component = React.memo(Presentational);

const Wrap = styled.div``;

type ElementProps = Props["inputProps"] & {
  isError: Props["isError"];
};
const Element = styled.input<ElementProps>`
  padding: 4px 12px;
  border: 1px solid #333;
  border-radius: 6px;

  ${(props) =>
    props.isError &&
    css`
      border-color: red;
    `}
`;

const ErrorMessage = styled.p`
  color: red;
`;

それを以下のような container component でラップして使い、

import * as React from "react";
import { Component } from "./presentational";

type Props = React.ComponentProps<typeof Component>;

export const Input = (props: Props) => {
  return <Component {...props} />;
};

開発者はこの container component を使うことでコンポーネントを扱うこととする。

presentational の Props で定義してある

type Props = React.ComponentPropsWithRef<"div"> & {
  isError?: boolean;
  errorMessage?: string;
  inputProps?: React.ComponentPropsWithRef<"input">;
};

は、この Props を渡すとどういう挙動になるのかが書いていないので、説明不足に見える。
TS では、JSDoc annotation を使えば、補足を書くことができるので、

type Props = React.ComponentPropsWithRef<"div"> & {
  /**
   * errorが発生したかどうか
   */
  isError?: boolean;

  /**
   * isError = trueの場合に表示するエラーメッセージ
   */
  errorMessage?: string;
  /**
   * input elementへ渡すprops
   */
  inputProps?: React.ComponentPropsWithRef<"input">;
};

としよう。
これで先程よりは Props が何を期待しているのかわかりやすくなった。

Props を解析して Markdown table で書く

コンポーネントと同じディレクトリに、コンポーネントについていろいろな情報を記載した README.md があれば、そのコンポーネントを使いたい場合にそこを見ればいいのでとても助かるはず。
しかし、メンテナンスコストは下げたいので、コンポーネントを解析してある程度のことは自動で記入してもらいたい。
特に Props はコンポーネント開発中は結構な頻度で追加や変更をすると思うので、楽をしたい。
そこで、styleguidist/react-docgen-typescriptを使う。

Storybook でも使われているライブラリで、CSF ファイルで default export したコンポーネントの argType を解析してドキュメントに記述するのに使っているArgTypes

これを使うと、例えばこういうコンポーネントがあったとき、

import * as React from "react";

import styled, { css } from "styled-components";

type Props = {
  /**
   * font size
   */
  variant: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body";
  children: React.ReactNode;
};

const Typography: React.FC<Props> = (props) => {
  const { children, ...restProps } = props;

  return <Element {...restProps}>{children}</Element>;
};

export const Component = React.memo(Typography);

type ElementProps = {
  variant: Props["variant"];
};
const Element = styled.p<ElementProps>`
  ${(props) => {
    switch (props.variant) {
      case "h1":
        return css`
          font-size: "30px";
        `;
      case "h2":
        return css`
          font-size: "28px";
        `;
      case "h3":
        return css`
          font-size: "24px";
        `;
      case "h4":
        return css`
          font-size: "22px";
        `;
      case "h5":
        return css`
          font-size: "20px";
        `;
      case "h6":
        return css`
          font-size: "18px";
        `;
      case "body":
        return css`
          font-size: "16px";
        `;
    }
  }}
`;
import * as docgen from "react-docgen-typescript";

const propsInfo = docgen.parse("Component.tsx", {
  skipChildrenPropWithoutDoc: false,
});
console.log(JSON.stringify(propsInfo, null, 2));
[
  {
    tags: {},
    filePath: "Component.tsx",
    description: "",
    displayName: "Component",
    methods: [],
    props: {
      variant: {
        defaultValue: null,
        description: "font size",
        name: "variant",
        declarations: [
          {
            fileName: "Component.tsx",
            name: "TypeLiteral",
          },
        ],
        required: true,
        type: {
          name: '"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body"',
        },
      },
      children: {
        defaultValue: null,
        description: "",
        name: "children",
        declarations: [
          {
            fileName: "Component.tsx",
            name: "TypeLiteral",
          },
        ],
        required: true,
        type: {
          name: "ReactNode",
        },
      },
    },
  },
];

というように、対象となるコンポーネントを parse して、Props 情報を含む情報を取得できる。しかも、Props では、descriptionフィールドに、先程の JSDoc で書いたコメント内容がそのまま入ってくる。
このデータを扱うことで、
このような Props table を自動で作ることが可能になる

自動で markdown に Props table を記入させる

もしかしたら、すでにいいツールがあるかもしれないが、今回は自作した
ここでは、

npm run insert-props-table src/components/Input/container.tsx src/components/Input/README.md

とすることで、対象となるコンポーネントファイルの Props table を指定した Markdown ファイルに記入させるというインターフェースにした。

実装の詳細は省くが、

  • Markdown ファイル中に <!-- start generate props table --><!-- end generate props table -->のセットとなるコメントアウトを見つける
  • そのセットの間を、parse した結果をごにょごにょして作った Markdown table で書き換える

だけ。
そうするだけで table が作れる。

終わりに

メンテナンスされる期間が長いコンポーネントは、開発者仲間にも、未来の自分にも優しいドキュメントを書くことで、ストレスを緩和させたいものだ。 だが、ストレスを緩和させるためにまた別のストレス(ここではドキュメント書くこと)を生み出すのは極力避けたいので、自動化できるところはさせて、「怠惰」を実現しよう。