React 本体のみで Redux フレンドリーな非同期処理を実現するアイデア

こんにちは。2019 年 10 月に join した kazuma1989 です。スタディサプリENGLISH の Web 版を担当するフロントエンドデベロッパーです。

今回は、ReactRedux を用いたアーキテクチャーで問題になる 非同期処理 を扱うアイデアの一つをご紹介します。Redux フレンドリー と題したように、Redux でなくとも(useReducerunstated-next でも)適用することができます。

アイデアを EffectComponent と名付けました。そもそも Redux において非同期処理の何が問題なのか、そして EffectComponent とは何か説明します。React Hooks と Redux についてある程度知っていることを前提とします。1)言語は TypeScript です。がんばらないで始める のがおすすめです

Redux + 非同期 は何が問題なのか

Redux と非同期処理を組み合わせたときの問題は、Redux が非同期的な状態変更をサポートしていないことから生じます。(サポートしていないこと自体は問題ではないです2)機能不足ではなく、予測可能な状態管理のためによく考えられた意図的なデザインのため

サポートされない一方で、現実的に非同期処理(たとえば API 通信)は必須で、コードのどこかに非同期処理を書かなければいけません。しかし、それらを どこにどのように書くか は難しく、それが問題になります。自前の規約やサードパーティーライブラリーを用いることは簡単ですが

  • 要件に合わなかったり、開発効率がよくなかったりする(ボイラープレートが多い、IDE 補完と相性が悪い)
  • デバッグやメンテのしやすい設計/実装がわからない、できない
  • Redux の機能に制限をかけてしまうか、逆に Redux を足かせにする
  • 独自の作法を覚えないといけない

といったデメリットを生じることがあるからです。

当然、ここで紹介する EffectComponent も上記のデメリットを生じ得ます。しかし React 本体の機能のみ を使うことにより、4 点目以外は克服できている(少なくとも React 以下になることはない)と思います。また、4 点目の学習コストに関しても、不必要に大きくないと思います。

EffectComponent というアイデア

EffectComponent とは次のようなコンポーネントです。特定のワード (query) を含む投稿を検索する処理です3)API は JSONPlaceholder です

import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Dispatch } from 'redux'
import { RootState, Actions } from './reducer'
export default function SearchPostsAPI() {
  // Store の state から必要な値(変化を監視したい値)を切り出す
  // タプルを使って複数の値を返しているが、オブジェクトを返すのでもいいし、複数 useSelector を呼ぶのでもよい
  const [query, status] = useSelector((state: RootState) => [
    state.query,
    state.postsStatus,
  ])
  const dispatch = useDispatch<Dispatch<Actions>>()
  // メインの処理
  // 処理の中身は自由だが、次の構成がおすすめ:
  // 1. 過剰に処理を呼ばないためのガード節
  // 2. 開始を告げるアクション
  // 3. 非同期処理
  // 4-a. 正常終了を告げるアクション
  // 4-b. 異常終了を告げるアクション
  useEffect(() => {
    // useEffect の引数に async 関数は渡せないので、内部で async な IIFE を呼ぶ
    ;(async () => {
      // 1.
      if (status !== 'waiting') return
      // 2.
      dispatch({
        type: 'API.Posts.Start',
      })
      try {
        // 3.
        // 非同期処理として代表的な、API の呼び出し(サンプルなので雑な処理)
        // ライブラリーを使ったり、関数として切り出したりしてもよい
        const posts = await fetch(
          `https://jsonplaceholder.typicode.com/posts?q=${query}`,
        ).then<
          {
            userId: number
            id: number
            title: string
            body: string
          }[]
        >(r => r.json())
        // 4-a.
        dispatch({
          type: 'API.Posts.Complete',
          payload: {
            query,
            posts,
          },
        })
      } catch (error) {
        // 4-b.
        dispatch({
          type: 'API.Posts.Error',
          payload: {
            query,
            error,
          },
          error: true,
        })
      }
    })()
  }, [query, status])
  // DOM を出力する必要はないので null を返す
  return null
}

useEffect が大部分を占めており、コンポーネントでありながら JSX.Element を返しません。また、外部状態として Redux の store のみに依存し props を受け取りません。そのため画面のコンポーネント階層とは独立で、つまり検索処理を呼び出す submit ボタンを含むコンポーネントや検索結果を表示するコンポーネントと依存し合うことなく、配置することができます。

EffectComponent の使い方

検索画面プレビュー

画像のようなアプリがあったとして、SearchApp コンポーネントが画面全体、SearchInput と SearchResults がそれぞれ入力フォームと検索結果を表すとすると、次のようになります:

import React from 'react'
import SearchPostsAPI from './SearchPostsAPI'
import SearchInput from './SearchInput'
import SearchResults from './SearchResults'
export default function SearchApp() {
  return (
    <>
      <SearchPostsAPI />
      <SearchInput />
      <SearchResults />
    </>
  )
}
import React, { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Dispatch } from 'redux'
import { Container, Title, SearchForm } from './components'
import { RootState, Actions } from './reducer'
export default function SearchInput() {
  const status = useSelector((state: RootState) => state.postsStatus)
  const dispatch = useDispatch<Dispatch<Actions>>()
  const [query, setQuery] = useState('')
  return (
    <Container>
      <Title>Search posts</Title>
      <SearchForm
        text={query}
        onChange={setQuery}
        disabled={status === 'loading'}
        onSubmit={() =>
          dispatch({
            type: 'Search.Posts.Submit',
            payload: {
              query,
            },
          })
        }
      />
    </Container>
  )
}
import React from 'react'
import { useSelector } from 'react-redux'
import { Container, Title, Post } from './components'
import { RootState } from './reducer'
export default function SearchResults() {
  const posts = useSelector((state: RootState) => state.posts)
  return (
    <Container>
      <Title>Results</Title>
      {posts.map(({ id, title, body }) => (
        <Post key={id} title={title} body={body} />
      ))}
    </Container>
  )
}

SearchApp は、SearchInput と SearchResults を配置して表示することのほか、この画面で SearchPostsAPI を使うことを宣言しているだけです。SearchInput と SearchResults も非常にシンプルで、ユーザー入力を Redux store へ渡したり、Redux store から取得した値を表示したりするだけです。これは、EffectComponent である SearchPostsAPI が、Redux store と API を仲介することによって実現されています。

EffectComponent を使うメリット

EffectComponent のメリットは、前述のように、ほかのコンポーネントを非常にシンプルに保てる点です。また、Redux store もコア機能のみでシンプルに使えるよう保っている点です。React 本体の機能のみを使うことにより、Redux に middleware を挟み込んだり、「actionCreator を使って Redux action を生成しなければならない」といった制約を設けたりしなくても済むようにしているのです。

ほかのコンポーネントがシンプルに保てる理由は、データの流れを見ることで理解できます:

data flow

1) SearchInput が入力値を store へ渡す
2-4) SearchPostsAPI が変更を検知して API を呼び出し、取得した値を store へ渡す
5) SearchResults が値を表示

SearchInput と SearchResults は API と一切の接点を持たず、ユーザー入力が何に使われるか、誰がいつ posts の値を取得したかについて知ることがありません。コンポーネントの責務が限定され、メンテナブルになっています。また SearchPostsAPI 自身も Redux store と API 以外には接点を持たない、つまりユーザー入力から query の値がやってきたことも posts の値が画面に表示されることも知らないため、画面デザインの変更に影響されません。

まとめ

  • React + Redux アーキテクチャーにおいて非同期処理をどこにどのように書くかは難しい問題
  • EffectComponent は React 本体の機能のみで非同期処理をすっきり収めることができる

EffectComponent のアイデアが優れるというより、非同期処理が、React Hooks の登場によってコア機能で十分書きやすくなったということかと思います。実際 EffectComponent は、その名称以外(useXxx のように use で始まる)カスタムフックの要件を満たしています。つまり、こう書いても動きます:

export default function SearchApp() {
  SearchPostsAPI()
  return (
    <>
      <SearchInput />
      <SearchResults />
    </>
  )
}

今回あえてコンポーネントとして配置したのは、性能チューニングの点で有利になると考えたからです。性能チューニングの話は、reducer の code splitting も含め、別の記事で解説しようと思います。

参考コード

参考のため、具体例が登場していなかった reducer とエントリーポイントのコードを載せておきます。EffectComponent のための特別なことを一切していないことがわかります。

import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import reducer from './reducer'
import SearchApp from './SearchApp'
const store = createStore(reducer)
ReactDOM.render(
  <Provider store={store}>
    <SearchApp />
  </Provider>,
  document.getElementById('root'),
)
export type RootState = {
  query: string
  postsStatus: 'initial' | 'waiting' | 'loading' | 'complete' | 'error'
  posts: {
    userId: number
    id: number
    title: string
    body: string
  }[]
}
export type Actions =
  | {
      type: 'Search.Posts.Submit'
      payload: {
        query: string
      }
    }
  | {
      type: 'API.Posts.Start'
    }
  | {
      type: 'API.Posts.Complete'
      payload: {
        query: string
        posts: {
          id: number
          userId: number
          title: string
          body: string
        }[]
      }
    }
  | {
      type: 'API.Posts.Error'
      payload: {
        query: string
        error?: unknown
      }
      error: true
    }
export default function reducer(
  state: RootState | undefined = {
    query: '',
    postsStatus: 'initial',
    posts: [],
  },
  action: Actions,
): RootState {
  switch (action.type) {
    case 'Search.Posts.Submit': {
      const { postsStatus } = state
      if (postsStatus === 'waiting' || postsStatus === 'loading') {
        return state
      }
      const { query } = action.payload
      return {
        ...state,
        query,
        postsStatus: 'waiting',
      }
    }
    case 'API.Posts.Start': {
      return {
        ...state,
        postsStatus: 'loading',
      }
    }
    case 'API.Posts.Complete': {
      const { posts } = action.payload
      return {
        ...state,
        postsStatus: 'complete',
        posts,
      }
    }
    case 'API.Posts.Error': {
      return {
        ...state,
        postsStatus: 'error',
      }
    }
    // switch-case に漏れがないか never 型で検証
    default: {
      const _: never = action
      return state
    }
  }
}

脚注

脚注
1 言語は TypeScript です。がんばらないで始める のがおすすめです
2 機能不足ではなく、予測可能な状態管理のためによく考えられた意図的なデザインのため
3 API は JSONPlaceholder です