🐥

ReactにStateパターンを取り入れて複雑な状態管理から脱却する

2024/12/27に公開

こんにちは、PortalKeyの植森です。

今回は、最近たまに使っているReactにStateパターンを使う実装を、他メンバーへの説明も兼ねて紹介します。
React18・19で非同期処理をシンプルに扱うためのAPIが増えたので使うシーンは減ってきていそうですが、実装パターンとして覚えておいて損はないと思います。

Stateパターン

Stateパターンはあるオブジェクトに関する状態と状態の振る舞いを表現できるデザインパターンの1つです。

ステートマシンの状態それぞれをオブジェクトとして扱うことで条件分岐を減らし、状態ごとの振る舞いをカプセル化することが出来ます。

https://zenn.dev/twugo/books/21cb3a6515e7b8/viewer/b48713

Stateパターンが有効なReactコンポーネント

シンプルなReactコンポーネントはpropsによって動作が変化しますが、複数のuseStateによって表示内容が変わるようなコンポーネントでは分岐が複雑になったりします。

例として以下のようにユーザを表示し、ユーザ名の変更をリクエストするフォームがあるとします。
※例なので「今どきこんなコード書かないだろ」というツッコミはなしで

const UserModal = ({ userId }: { userId: string }) => {
  const [user,  setUser] = useState<User | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [requesting,  setRequesting] = useState(false)
  const [name, setName] = useState("")

  const onClick = useCallback(async () => {
    if (requesting  ||  name === "") {
      return
    }

    setRequesting(true)

    const token = localStorage.getItem("token")

    try {
      await fetch(`https://api.example.com/users/${userId}`,  {
        method: "POST", 
        body: JSON.stringify({ userId, name }),
        headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
      })
    } catch (error) {
      setError(error)
    }
  }, [requesting, name])

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const user = await fetch(`https://api.example.com/users/${userId}`).then(res => res.json())
        setUser(user)
      } catch (error) {
        setError(error)
      }
    }
    fetchUser()
  }, [userId])

  if (error) {
    return (
      <Modal>
        <div>Error: {error.message}</div>
      </Modal>
    )
  }

  if (!user) {
    return (
      <Modal>
        <div>Loading...</div>
      </Modal>
    )
  }

  if (requesting) {
    return (
      <Modal>
        <div>{user.name}</div>

        <input value={name} readOnly />
        <button onClick={onClick} disabled>Saving...</button>
      </Modal>
    )
  }

  return (
    <Modal>
      <div>{user.name}</div>

      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={onClick} disabled={name  === "" || requesting}>Save</button>
    </Modal>
  )
}

このフォームは状態として以下のような分岐を持っています

  • ユーザのデータロードが終わっているか
  • ユーザの変更リクエスト中かどうか
  • いずれかのリクエストでエラーが発生したかどうか

エラーやローディング表示になる条件もいくつかありますが、整理すると特定の状態には特定の状態からしか遷移しないことや、特定の変数が特定の状態にしか関係がないことがわかります。
こういったコンポーネントはたまに必要になるケースが存在すると思います。

Stateパターンを使ったリファクタリング

ではこのコンポーネントをStateパターンを使ってリファクタリングします。

まず、Stateパターンとして状態を定義します。

type UserModalState = LoadingState | UserInputState | ErrorState | SavingState

interface LoadingState {
  state: "loading"
}

interface UserInputState {
  state: "userInput"
  user: User
}

interface ErrorState {
  state: "error"
  error: Error
}

interface SavingState {
  state: "saving"
  user: User
  input: string
}

各Stateには必要なもののみ定義します。
そして、これらのStateに対しそれぞれの状態毎のコンポーネントを定義します。

type SetState = (state: UserModalState) => void

const UserModalLoading = ({ state, setState }: { state: LoadingState; setState: SetState }) => {
  useEffect(() => {
    const fetchUser = async () => {
      try {
        const user = await fetch(`https://api.example.com/users/${state.userId}`).then(res => res.json())
        setState({ state: "userInput", user })
      } catch (error) {
        setState({ state: "error", error: error as Error })
      }
    }
    fetchUser()
  }, [state.userId, setState])

  return (
    <Modal>
      <div>Loading...</div>
    </Modal>
  )
}

const UserModalInput = ({ state, setState }: { state: UserInputState; setState: SetState }) => {
  const { user } = state
  const [name, setName] = useState("")

  const onClick = useCallback(() => {
    setState({ state: "saving", user, input: name })

    const token = localStorage.getItem("token")

    fetch(`https://api.example.com/users/${user.id}`, {
      method: "POST",
      body: JSON.stringify({ userId: user.id, name }),
      headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
    }).then(() => {
      setState({ state: "userInput", user: { ...user, name } })
    }).catch((error) => {
      setState({ state: "error", error: error as Error })
    })
  }, [user, name, setState])

  return (
    <Modal>
      <div>{user.name}</div>

      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={onClick} disabled={name === ""}>Save</button>
    </Modal>
  )
}

const UserModalError = ({ state }: { state: ErrorState }) => {
  return (
    <Modal>
      <div>Error: {state.error.message}</div>
    </Modal>
  )
}

const UserModalSaving = ({ state }: { state: SavingState }) => {
  const { user, input } = state

  return (
    <Modal>
      <div>{user.name}</div>

      <input value={input} readOnly />
      <button disabled>Saving...</button>
    </Modal>
  )
}

各Stateのコンポーネントを定義したら、最初のコンポーネントでstateごとの分岐を作ります。

const UserModal = ({ userId }: { userId: string }) => {
  const [state, setState] = useState<UserModalState>({ state: "loading", userId })

  // 型判別によりUserModalStateがそれぞれのStateの型と判別される
  switch (state.state) {
    case "loading":
      return <UserModalLoading state={state} setState={setState} />
    case "userInput":
      return <UserModalInput state={state} setState={setState} />
    case "error":
      return <UserModalError state={state} />
    case "saving":
      return <UserModalSaving state={state} />
  }
}

TypeScriptではUnion型がリテラル型のメンバを持つ場合、そのプロパティを使用してUnion型のメンバの型を判別することが可能で、ここではこの型判別を利用して条件分岐と渡すStateの具象化を行っています。
https://typescript-jp.gitbook.io/deep-dive/type-system/discriminated-unions

それぞれのコンポーネントから状態分岐がなくなり、どのタイミングでどの表示に遷移するのかがわかりやすくなったと思います。
また、 useState のみで管理していた時は状態が変数の数と状態の掛け算になっていましたが、状態がUIの状態遷移の数だけになることで管理する状態自体が減りました。

ちなみに react-routerNavigate コンポーネントなどと組み合わせると、他の画面の遷移を状態遷移として表せたりします。

const UserModal = ({ userId }: { userId: string }) => {
  const [state, setState] = useState<UserModalState>({ state: "loading", userId })

  // 型判別によりUserModalStateがそれぞれのStateの型と判別される
  switch (state.state) {
    case "loading":
      return <UserModalLoading state={state} setState={setState} />
    case "userInput":
      return <UserModalInput state={state} setState={setState} />
    case "error":
      return <UserModalError state={state} />
    case "saving":
      return <UserModalSaving state={state} />
+   case "completed":
+     return <Navigate to="/" />
  }
}

参考

ReactコンポーネントにStateパターンを導入するアプローチは aws-amplify の認証フォームの設計を見て参考にしました。

https://docs.amplify.aws/react/build-a-backend/auth/connect-your-frontend/sign-up/

aws-amplifysignup() などの各認証用関数が nextStep を返すため、それに応じた処理や表示を行えるようになっています。
この例では useEffect で呼んでいる各リクエストなどは直接実装を書いていますが、外部に切り出したうえで nextState を返すようにしても良いかもしれません。

こうした設計はリクエストを行う関数をコンポーネントから切り離しつつ、関数単体を細かい条件に応じて正しいStateが返ってくるかどうかをテストしやすいなどのメリットもありそうです。

まとめ

Reactコンポーネントで useStateuseEffect による状態はpropsのみの純粋関数なコンポーネントよりも依存が増え、どうしても複雑になりがちです。
古典的なデザインパターンであるStateパターンは有効なシーンもあるのではないでしょうか。

最近ではReact18から導入された Suspense や、React19で導入された use をはじめとした各種非同期ステートを扱うhooksなどで解決出来るケースも増えてきました。
しかし、ユースケースによってはReactやライブラリの機能に頼らなくても設計で解決できるシーンもあると思うので、 useState を多用しすぎないようにしていきましょう。

PortalKey Tech Blog

Discussion