React.useStateは2種類ある


React.useStateにはある点で2種類あると考えている。

  • UI制御専用のもの
  • そうでないもの

これはUIコンポーネントに含められるかどうか、の違いがある。

UIコンポーネント

UI制御専用のものとは。 テキストをマスクしたりしなかったりするコンポーネントを考える。(type=“password"は便宜上使用。)

const [mask, setMask] = React.useState(false);
return (
    <>
        <input type={view ? "text" : "password"} />
        <span onClick={() => setMask(!v => v)}>toggle</span>
    </>
)

入力された文字を表示するかどうかを、UIの責務の範囲内で完結されるべきと言えるときは、React.useStateが使って実装するのが良い(かもしれない)。 言い換えれば、UIコンポーネントとして表示/非表示を制御すると決めた場合、React.useStateを使っても問題はない。

強めに言えば、大体のUIコンポーネント内のReact.useStateは、表示/非表示を制御するのみと言っても良い。逆に言えば、それ以外のReact.useStateは、UIコンポーネントに含めるべきではないと考え始めると良いかもしれない。

UIコンポーネント外

先の例で言えば、input.valueに何を入力しているかは、UIコンポーネントの責務範囲外で、外部から受け入れるべき。

type Props = Pick<React.ComponentProps<"input">, "value">;
function MaskedTextInput(props: Props) {
}

便利なUIコンポーネントを作ろうとしてか、ビジネスロジックや他APIとの通信までコンポーネントに含まれてしまうパターンはよくありがちか。

設計方針次第ではあるが、ピュアなUIコンポーネントを作ろうと思ったときには、参考になるかもしれない。

いくつか例がある方がわかりやすいかもしれない。 React.useStateよりもコンポーネントの責務や命名のほうが主題かもしれない。

クリップボードにコピーするコンポーネント

クリップボードに表示する値が動的に変化する場合は、それはおそらく呼び出し元で定義され流し込まれるべきかもしれない。 ここで利用したReact.useStateは、コピーしたらチェックマークを表示するようにするために利用されている。

function CopyInput(inputProps: React.ComponentProps<"input">) {
    const [copied, setCopied] = useState(false);
    const buttonProps = {
        onClick: () => {
            navigator.clipboard.writeText(inputProps.value)
            .then(() => setCopied(true))
            .catch((e) => console.error(e));
        }
    }
    React.useEffect(() => {
        let f = false;
        setTimeout(() => {
            if(f) return;
            setCopied(false);
        }, 2000);
        return () => {
            f = true
        }
    }, [copied]);
    return (
        <>
            <input {...inputProps}/>
            <button {...buttonProps}>
                {copied ? <CheckIcon />: <Copy />}
            </button>
        </>
    )
}

入力途中で検索結果をリストするコンポーネント

inputの入力内容から候補をリストできるコンポーネント これは、おそらくReact.useStateをUIコンポーネント内では使わないかもしれない。

type Props = React.ComponentProps<"div"> & {
    inputProps: React.ComponentProps<"input">;
}
function SuggestInput({inputProps, ...props}: Props) {
    return (
        <div> {/* relative */}
            <input {...inputProps} />
            {/* 検索候補(absolute) */}
            <div {...props}/>
        </div>
    )
}

// 参照側
return (
    <main>
        <SuggestInput>
            {/*検索候補*/}
            {suggests.map(...)}
        </SuggestInput>
    </main>
)

このUIコンポーネントの責務は、検索候補をきれいに表示できるようなレイアウトを持っていること、程度になるかもしれない。

例えば、ユーザ検索の検索候補を出す場合には、input.onChangeを受け取る、検索する、候補UIを生成するの一連の状態は、このコンポーネントの責務外である(単に表示非表示を超えているので)、どこか他の部分でReact.useStateを持っていると良い。

では、検索中はローディングを表示したい場合は、、これも同様にReact.useStateは検索しているどこかで持つべきで、UI側は、おそらく下のように受け取るだけになる。

type Props = React.ComponentProps<"div"> & {
    inputProps: React.ComponentProps<"input">;
    loadingView?: boolean;
}

補足: コンポーネントの命名

命名として、ローティングを表示するかがもっとも単純で適切な可能性がある。 検索しているかは呼び出し元の都合で、もしかしたらもっと単純に取得している場合も状況としてあり得るかもしれない。 検索か取得かは呼び出し元の命名で悩むことであって、UIコンポーネントを考える場合は特定のコンテキストを考えずに、 UIがローディングを表示するか、という点に集中すると良い設計になるかもしれない。

まとめ

コンポーネントの責務を明確にして、React.useStateを使うべきかを考えると、使いやすいUIコンポーネントが設計できるかもしれない。

./componentsに切り出してもなんとなく散らかっているように見えたり、そもそも切り出すべきではなかったり、なぜかを考えると、原因の一つ(結果のひとつ)として、React.useStateで色々やり(便利?にし)始めるからではと思い至った。 「React.useStateをUIコンポーネントで利用するべきではない、ただし例外として表示するかどうかの制御は持てる」と仮定して、しばらくコードを書いてみると思いの外しっくり来た。

良い設計を実践するための一つのツールとしてReact.useStateは2種類あることを意識すると良いかもしれない。