useSelectorとuseDispatchでcontainer componentを排除する

f:id:koki0906:20211201213900p:plain f:id:koki0906:20211201213227p:plain f:id:koki0906:20201029161030p:plain

はじめに

こんにちは。株式会社スタメンでフロントエンドエンジニアをしております@0906kokiです。

React Hooksが2019年にリリースされてから、Reduxの実装でコンポーネントがstoreと接続する方法に選択肢が増えました。

具体的に言うと、今までconnect関数でstoreにあるstateやactionをpresentational componentに対してpropsとして渡していましたが、hooks時代ではuseSelectorとuseDispatchによってconnect関数を書かなくても、storeとの接続が可能になりました。

実装の広がりが出たと同時に、connect関数useSelector + useDispatchのどちらを使えばいいのか、いざ実装しようと思った際に悩むかもしれません。

現在、弊社では後者のuseSelectorとuseDispatchを使った実装方法でプロジェクトを進めており、今回の記事ではcontainer componentをuseSelectorとuseDispatchで置き換える実装の知見を共有したいと思います。

container componentの課題

container componentを実装するにあたって難しい問題は、どの粒度でcontainer層を注入するかだと思います。 大抵は、親コンポーネントに対してcontainer componentを入れ、その親コンポーネントから子供や孫にpropsを渡していくと思います。

しかし、子供や孫が増えてきた時に、大量のpropsバケツリレーが発生して一気に見通しが悪化していきます。

そこで、その親と孫の間に中間層としてcontainer componentを注入し、propsのバケツリレーを回避することもできますが、container componentの実装はかなりのコストが発生します。できれば、アプリケーションに大量のcontainer componentは書きたくありませんし、container componentを注入するタイミングを毎回考えたくありません。

useSelectorとuseDispatch

そこで、react-redux v7.1.0のhooks対応で導入された、useDispatchuseSelectorでこの問題に立ち向かいたいと思います。

軽くuseSelectorとuseDispatchの説明をすると、

useSelector

  • storeのstateをpresentational componentへ持ってくることができる
  • actionがdispatchされると実行される

useDispatch

  • actionをdispatchするために使用する
  • storeを変更しない限り、返り値のdispatch関数は変更されない

実装方法

今回container componentsをuseDispatchとuseSelectorに書き換えたアプリケーションを実装したいと思います。 例えば、以下のようなcontainer componentとpresentational componentのコードがあるとします。

// containers/Todo.ts

import { Dispatch } from 'redux'
import { connect } from 'react-redux'
import { UserState } from 'types/user'
import { TodoState, Todotype } from 'types/todo'
import { RootState } from 'reducers'

export type TodoProps = {
  users: UserState,
  todos: TodoState,
  addTodo: (todo: TodoType) => void
}

const mapStateToProps = (state: RootState) => {
  return {
    users: state.users,
    todos: state.todos
  }
}

const mapDispatchToProps = (dispatch: Dispatch) => {
  return {
    addTodo(todo: TodoType) => {
      dispatch(addTodo(todo: TodoType))
    },
    fetchTodos() => {
      dispatch(fetchTodos())
    }
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoComponent)
// components/Todos/TodoComponent.tsx

import { TodoProps } from 'containers/Todo.ts'

const TodoComponent = (props: TodoProps) => {
  const { users, todos, addTodo, fetchTodos } = props
  const { isLoading, todoItems } = todos
  
  useEffect(() => {
    fetchTodos()
  }, [])
  
  if (isLoading) return (<></>)
  
  const todoList = todoItems.map(item => (
    <ChildComponent item={item} users={users} addTodo={addTodo} />
  ))
  
  return (
    <>
       {todoList}
    </>
  )
}

まずは、このコンポーネントをuseDispatchとuseSelectorに置き換えたいと思います。 container componetを削除して、TodoComponentへ直接storeのstateとdispatch関数を持ってきます。

// components/Todos/TodoComponent.tsx

import { useSelector, useDispatch } from 'react-redux'
// import types
import { RootState } from 'reducers'
import { TodoState } from 'types/todo'
import { UserState } from 'types/user'
// import actions
import { addTodo, fetchTodos } from 'actions/todo'

const TodoComponent = () => {
  const dispatch = useDispatch()
  const { users } = useSelector<RootState, UserState>(state => state.users, shallowEqual)
  const { todoItems, isLoading } = useSelector<RootState, TodoState>(state => state.todos, shallowEqual)

  useEffect(() => {
    dispatch(fetchTodos())
  }, [])
  
  if (isLoading) return (<></>)
 
  const todoList = todoItems.map(todo => (
    <ChildComponent todo={todo} users={users} addTodo={dispatch(addTodo()} />
  ))

  return (
    <>
     {todoList}
    </>
  )
}

container componentがなくなり、すっきりしましたね。ただ、このコードにはいくつかの問題点が存在します。

  1. usersやtodoItemsなどのstateが複数のファイルで必要になった場合に、毎回同じuseSelectorを書かないといけない(型ファイルのimportやdispatch関数も同様)
  2. useSelectorの返り値をobjectにしているため、コンポーネントに必要なstate以外が更新された場合でも、レンダリングが走る

2に関しては、意外と盲点かと思います。 サンプルとして以下のようなコードを用意しました。(importやtypeは省略しています)

// App.tsx

const App = () => {
  return (
    <div>
      <TodoItem />
      <Memo/>
    </div>
  )
}

// components/Todos/Items.tsx

const TodoItem = () => {
  const [keyword, setKeyword] = useState('')
  const dispatch = useDispatch()
  const { todoItems } = useSelector(state => state.todos)

  const handleOnChage = (e: React.ChangeEvent<HTMLInputElement>) => [
    setKeyword(e.target.value)
  ]
  
  const handleAddTodo = () => {
    dispatch(addTodo(keyword))
    setKeyword('')
  }

  const renderTodoList = todoItems.map(todo => {
    return (
      <li>{todo}</li>
    )
  })

  return (
    <ul>
      <h2>TODOリスト</h2>
      {renderTodoList}
      <input
        value={keyword}
        onChange={handleOnChage}
      />
      <button onClick={handleAddTodo}>追加</button>
    </ul>
  )
}

// components/Todos/TodoMemo.tsx

const TodoMemo = () => {
  const dispatch = useDispatch() 
  const { memo } = useSelecto(state => state.todos)

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch(changeMemo(e.target.value))
  }
  
  return (
    <div>
      <h2>TODOメモ</h2>
      <input 
        value={memo}
        onChange={handleOnChange}
      />
    </div>
  )
}

App.tsxが親コンポーネントで、その配下にTodo追加・羅列するItems.tsx, Todoのメモを書くTodoMemo.tsxがあります。 TodoMemoにあるchangeMemoをdispatchしてmemoのstateを更新しても、Items.tsxがレンダリングされないことが理想です。

しかし、実際は以下のようにItems.tsxにもレンダリングが走っています。

useSelector_rendering

こうした無駄なレンダリングや冗長なコードを解消するために、以下の様にselectors/todo.tsのファイル作成して、そこのファイルから必要なdispatch関数やstateを取得することにしたいと思います。

// selectors/todo.ts

import { useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
// import actions
import { addTodo, fetchTodos } from 'actions/todo'
// import types
import { TodoType } from 'types/todo'

const useTodoDispatchActions = () => {
  const _fetchTodos = useCallback(() => dispatch(fetchTodos()), [dispatch])
  const _addTodo = useCallback((todo: TodoType) => dispatch(addTodo(todo)), [dispatch])
  
  return {
    fetchTodos: _fetchTodos,
    addTodo: _addTodo
  }
}

export const useSelectTodoItems = () => {
  return useSelector<RootState, todoType[]>(state => state.todos.todoItems)
}

export const useSelectIsLoading = () => {
  return useSelector<RootState, boolean>(state => state.todos.isLoading)
}
// components/Todos/TodoComponent.tsx

import { useTodoDispatchActions, useSelectTodoItems, useSelectIsLoading } from 'selectors/todo'
// import selectors
import { useSelectUsers } from 'selectors/todo'

const TodoComponent = () => {
  const { addTodo, fetchTodos } = useTodoDispatchActions()
  const todoItems = useSelectTodoItems()
  const isLoading = useSelectIsLoading()
  
  useEffect(() => {
    dispatch(fetchTodos())
  }, [])
  
  if (isLoading) return (<></>)
 
  const todoList = todoItems.map(todo => (
    <ChildComponent todo={todo} users={users} addTodo={dispatch(addTodo()} />
  ))

  return (
    <>
     {todoList}
    </>
  )
}

上記の様に、selector関数とdispatch関数をまとめたselectorファイルを切り、そのファイルからstateやdispatch関数をimportすることで、そのコンポーネントで本当に必要なものだけ取得できる + レンダリングがそのコンポーネントにだけに閉じたものになります。

コードの解説をすると、useTodoDispatchActions関数でdispatch関数を返却しています。 また、stateに関しては、objectで一括に返却するのではなく、個別のstateをそれぞれexportしています。 そして、TodoComponentにて必要なstateとdispatch関数を上記のファイルからimportしています。

こうすることで、わざわざcontainer componentを実装せずに、selector関数を実装するだけで、それぞれのコンポーネントにて必要なstateとdispatch関数をimportするだけで済みます。

まとめ

Reduxの実装に関しては、所属する会社やチーム、プロジェクトの内容によってバラツキが出ると思いますが、弊社ではcontainer componentをuseSelectorとuseDisptchに代替して実装しました。

現時点の感触ですが、container componentを実装するよりも見通しがよく、実装コストも低いと感じています。また、container componentを注入する粒度に関しても考える必要がないので、スムーズに開発ができています。

今まで実装していた痛みが新しい技術で解消されていくことは楽しいので、これからもキャッチアップしていきたいと思います。

株式会社スタメンでは一緒に働くエンジニアを募集しています。ご興味のある方はぜひエンジニア採用サイトをご覧ください。