【React小ネタ】ユーザーの入力をawaitするウィザード風Hook

# 序

「ステップごとに入力していき、全部入力したら確定する」というウィザード風のUIにおいて、入力終了時に`promise`が`resolve`して値が返ってくる、というような`Hook`を実装できないかなと思って、やってみたものです。

:::note warn
このようなことが可能なのかを知りたいというモチベーションで最低限の実装をやってみたものであり、実用上の問題がないかは未検証です。
:::

## 実装

まずは`Hook`のほう。

```tsx:useWizard.tsx
import {
  useState,
  useCallback,
  useEffect,
  HTMLInputTypeAttribute,
} from 'react';

const useWizard = (
  prompts: { label: string; inputType: HTMLInputTypeAttribute }[]
) => {
  const [isActive, setIsActive] = useState(false);
  const [step, setStep] = useState(0);
  const [values, setValues] = useState<string[]>(
    new Array(prompts.length).fill('')
  );
  const [resolver, setResolver] = useState<null | ((reuslt: string[]) => void)>(
    null
  );

  // ステップごとに入力させ、最終的に値を返す関数
  const getValues = useCallback(async () => {
    setIsActive(true);
    const promise = new Promise<string[]>((resolve) => {
      setResolver(() => (result: string[]) => {
        resolve(result);
      });
    });
    return promise;
  }, []);

  useEffect(() => {
    if (resolver && step === prompts.length) {
      // stepが進んでいき、最終ステップで「進む」を押したらresolveする。
      resolver(values);
      
      // 初期化
      setIsActive(false);
      setResolver(null);
      setStep(0);
      setValues(new Array(prompts.length).fill(''));
    }
  }, [resolver, step]);

  const Controller = useCallback(() => {
    if (!isActive || step === prompts.length) return null;
    // 入力フォームはpromiseの待機中にもstepに応じて変化
    return (
      <form>
        <label>{prompts[step].label}</label>
        <input
          type={prompts[step].inputType}
          defaultValue={values[step]}
          onChange={(e) => {
            setValues((prev) =>
              prev.map((v, i) => (i === step ? e.target.value : v))
            );
          }}
        />
        <button
          onClick={(e) => {
            e.preventDefault();
            setStep(step + 1);
          }}
        >
          進む
        </button>
        <button
          onClick={(e) => {
            e.preventDefault();
            setStep(step - 1);
          }}
        >
          戻る
        </button>
      </form>
    );
  }, [isActive, step]);

  return { getValues, Controller, step };
};

export default useWizard;
```
……`getValues`の`promise`を`useEffect`で解決してるのが気持ち悪いですね。

利用側はこんな感じで使います。
```tsx:App.tsx
import { useState } from 'react';
import useWizard from './hooks/useWizard';

function App() {
  // useWizard
  const { Controller, getValues } = useWizard([
    { label: '名前', inputType: 'text' },
    { label: 'メールアドレス', inputType: 'text' },
    { label: 'パスワード', inputType: 'password' },
  ]);
  
  const [values, setValues] = useState<string[]>();
  
  const handleClick = async () => {
    // 入力開始ボタンをクリックするとpromiseが開始されて、Controllerが表示される
    // 最後まで入力するとpromsieがresolveされてresultに値が入る
    const result = await getValues();
    setValues(result);
  };
  return (
    <>
      <button onClick={handleClick}>入力する</button>
      <Controller />
      {values && (
        <ul>
          {values.map((v, i) => (
            <li key={i}>{v}</li>
          ))}
        </ul>
      )}
    </>
  );
}

export default App;
```
こっちは割とシンプルです。

## デモ

Stackblitzでの動作デモです。
`入力する`をクリックするとフォームが出てきて、`進む`を押して入力を進め、最後に入力内容を表示します。

https://stackblitz.com/edit/vitejs-vite-lgn4xd?file=src%2Fhooks%2FuseWizard.tsx&terminal=dev

# 終わりに
今度個人開発のWebアプリにカメラを使った入力支援機能を付けようと思っていて、その前段階として、「**ユーザー入力をawaitしたい**」が実現可能かを試してみました。
上記のデモみたいに地べたに配置してしまうとわけわかんなくなるので、モーダルやドロワーと組み合わせて使うことを想定してます。

`promise`の`resolve`をよそに持っていくという実装はかなり**スパゲティ感**があるのですが、**ユーザー入力を関数化する**と考えれば、利用側はすごくシンプルに保てるんじゃないかと思ってます。