JavaScript
React
redux
react-redux
0

React Hooksでredux / react-reduxでやってたことを色々やってみる

React HooksはRFCの段階です。この記事はあくまで実験の産物としてお読み下さい。
また、出たばかりなので探り探りで書いています。何かある場合はコメントや編集リクエストをいただければ幸いです。

準備

React hooksは16.7にのみ予定されているので、下記のコマンドで16.7を入れる

yarn add react@16.7.0-alpha.0 react-dom@16.7.0-alpha.0

yarn add react@16.7とかでも選択が出来る)

1. useReducerでcombineReducersだけ使ってみる

reduxにおいてはcombineReducersを利用してネストすることが出来た。
これを組み合わせることをやってみる

例えばこんなreducer

import { combineReducers } from "redux"

const counter = (state = 0, action) => {
  switch (action.type) {
    case "INCREMENT":
      return state + 1
    case "DECREMENT":
      return state - 1
  }
  return state
}

const inputValue = (state = "foo", action) => {
  switch (action.type) {
    case "UPDATE_VALUE":
      return action.value
  }
  return state
}

export const rootReducer = combineReducers({
  counter,
  // サンプルとしてネストしてみる
  someNested: combineReducers({
    inputValue
  })
})

利用側はこんな感じ

import React, { useReducer } from "react"

const App = () => {
  const [state, dispatch] = useReducer(rootReducer, undefined, {
    type: "DUMMY_INIT"
  })

  return (
    <div className="App">
      <div>
        <h1>counter</h1>
        <div>count: {state.counter}</div>
        <button onClick={(e) => dispatch({ type: "INCREMENT" })}>+</button>
        <button onClick={(e) => dispatch({ type: "DECREMENT" })}>-</button>
      </div>
      <div>
        <h1>Input value</h1>
        <div>value: {state.someNested.inputValue}</div>
        <input
          value={state.someNested.inputValue}
          onChange={(e) =>
            dispatch({
              type: "UPDATE_VALUE",
              value: e.target.value
            })
          }
        />
      </div>
    </div>
  )
}

一点工夫する点として、initialStateとinitialActionをダミーでも渡してデフォルトのinitial値を使う必要があるようだ

const [state, dispatch] = useReducer(rootReducer, undefined, {
  type: "DUMMY_INIT"
})

2: createContextとuseContextを組み合わせてProviderを作る

useReducerだけだと孫までバケツリレーが必要になる。
ここはuseReducerにcontextを利用してみよう。
Contextを使うようなケースでも無いのかも?とは思ったが、一応公式にもこれに類似したドキュメントが存在しているようだ
https://reactjs.org/docs/hooks-faq.html#how-to-avoid-passing-callbacks-down

まずContexを作成。初期値は空
js
const ReducerContext = createContext()

Providerはこんな具合で書かれる

// ContextのProviderをラップしているだけ。値としてstateとdispatchを渡してしまうことにする
const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(rootReducer, undefined, {
    type: "DUMMY_INIT"
  })
  return (
    <ReducerContext.Provider value={{ state, dispatch }}>
      {children}
    </ReducerContext.Provider>
  )
}

const App = () => {
  return (
    <Provider>
      <div className="App">
        <Counter />
        <InputValue />
      </div>
    </Provider>
  )
}

利用側(Consumer)はuseContexを利用するとこうなる。
ちょっとだけuseContextの周りの値が不定で気持ち悪い感も否めない

const Counter = () => {
  const { state, dispatch } = useContext(ReducerContext)
  return (
    <div>
      <h1>counter</h1>
      <div>count: {state.counter}</div>
      <button onClick={(e) => dispatch({ type: "INCREMENT" })}>+</button>
      <button onClick={(e) => dispatch({ type: "DECREMENT" })}>-</button>
    </div>
  )
}
const InputValue = () => {
  const { state, dispatch } = useContext(ReducerContext)
  return (
    <div>
      <h1>Input value</h1>
      <div>value: {state.someNested.inputValue}</div>
      <input
        value={state.someNested.inputValue}
        onChange={(e) =>
          dispatch({
            type: "UPDATE_VALUE",
            value: e.target.value
          })
        }
      />
    </div>
  )
}

useContextを使わずConsumerを使うならこうだろう

const Counter = () => {
  return (
    <ReducerContext.Consumer>
      {({ state, dispatch }) => {
        return (
          <div>
            <h1>counter</h1>
            <div>count: {state.counter}</div>
            <button onClick={(e) => dispatch({ type: "INCREMENT" })}>+</button>
            <button onClick={(e) => dispatch({ type: "DECREMENT" })}>-</button>
          </div>
        )
      }}
    </ReducerContext.Consumer>
  )
}

3. useCallbackでbindActionCreactors っぽいこと

actionのbindをしたければuseCallbackが使えそうだ。
useCallbackはmemorizeしてくれる関数で、実は無くても動くが再レンダリングが起きてもmemo化されてくれる。引数として[dispatch]を与えているので、dispatchがもし変更された場合に再生成される(はず)

  const increment = useCallback((e) => dispatch({ type: "INCREMENT" }), [
    dispatch
  ])
  const decrement = useCallback((e) => dispatch({ type: "DECREMENT" }), [
    dispatch
  ])
  const updateValue = useCallback(
    (e) =>
      dispatch({
        type: "UPDATE_VALUE",
        value: e.target.value
      }),
    [dispatch]
  )
  return <div>
   :
    <button onClick={increment}>+</button>
    <button onClick={decrement}>-</button>
   :
  </div>

4. useMemoでmapStateToPropsでreselect使っていたような部分をやってみる

masStateToPropsでやっていたようなstateからpropsへの変換や、よくそこで使われていたreselectのmemo化機能をuseMemoで扱えそうだ。

const InputValue = () => {
  const { state, dispatch } = useContext(ReducerContext)
  // state.someNested.inputValueが変更されるまでmemo化する
  const inputValue = useMemo(() => state.someNested.inputValue, [
    state.someNested.inputValue
  ])

  return (
    <div>
      <h1>Input foo</h1>
      <div>foo: {inputValue}</div>
      <input
        value={inputValue}
        onChange={(e) =>
          dispatch({
            type: "UPDATE_VALUE",
            value: e.target.value
          })
        }
      />
    </div>
  )
}

5. Containerを再現する

ここまでの応用で、Containerのような作用をするhooksを作ってみる。
inputValueの方のみ例示する。Counterの場合も一緒だ。

const useCounterContext = () => {
  const { state, dispatch } = useContext(ReducerContext)
  const counter = useMemo(() => state.counter, [state.counter])
  const increment = useCallback(
    (e) => setTimeout(() => dispatch({ type: "INCREMENT" }), 500),
    [dispatch]
  )
  const decrement = useCallback((e) => dispatch({ type: "DECREMENT" }), [
    dispatch
  ])

  return { counter, increment, decrement }
}

const Counter = () => {
  const { counter, increment, decrement } = useCounterContext()
  return (
    <div>
      <h1>counter</h1>
      <div>count: {counter}</div>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  )
}

// containerという名前をつけるべきかはまだ悩ましい
const useInputContainer = () => {
  const { state, dispatch } = useContext(ReducerContext)
  // memo化したcallbackを作成
  const updateValue = useCallback(
    (e) =>
      dispatch({
        type: "UPDATE_VALUE",
        value: e.target.value
      }),
    [dispatch]
  )
  // 値をmemo化。selectのような作用
  const inputValue = useMemo(() => state.someNested.inputValue, [
    state.someNested.inputValue
  ])
  return {
    updateValue, inputValue
  }
}

const InputValue = () => {
  // Component側でContainerの作用をするhookを呼び出す。
  const { updateValue, inputValue } = useInputContainer()
  return (
    <div>
      <h1>Input foo</h1>
      <div>value: {inputValue}</div>
      <input value={inputValue} onChange={updateValue} />
    </div>
  )
}

// ここは相変わらず
const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(rootReducer, undefined, {
    type: "DUMMY_INIT"
  })
  const value = { state, dispatch }
  return (
    <ReducerContext.Provider value={value}>{children}</ReducerContext.Provider>
  )
}

できたもの

5まで行った状態のコードを下記に置いた
https://stackblitz.com/edit/github-hgrund?file=src/App.js

番外編:middleware

非同期処理をやるようなmiddlewareは、useEffectでも可能だが、これは推奨されない使い方で、将来的にSuspenseがその役割を担う可能性が高い ため、あくまで番外編としている。

まずreducerはこのような感じで生やす

const fetchedData = (state = {}, action) => {
  switch (action.type) {
    case "FETCH_DATA":
      return action.value
  }
  return state
}

そしてfetchに相当する関数を作る。
今回はデータ取得の代わりに100ms後に乱数を返してみる。

const fetchData = (dispatch) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ random: Math.random() })
    }, 100)
  })
  // 本当の通信であればこんな感じ
  // return fetch("./async.json")
  //   .then((res) => res.json())
  //   .then((data) => {
  //     return data
  //   })
}

そしてcontainer相当の部分の作成。
注意点としてuseEffectの第二引数を空配列とすることでmount,unmount時に一度だけ実行されるようにすること。
ここに渡した値が変更される度にeffectに指定した関数が実行される。
何もしない場合レンダリングの度に毎回実行されるので注意。
(参照:https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects)

const useFetchDataContainer = () => {
  const { state, dispatch } = useContext(ReducerContext)

  // 初回実行。第二引数を空arrayにすることで1度だけ実行にする
  useEffect(() => {
    fetchData().then((data) => {
      dispatch({
        type: "FETCH_DATA",
        value: data
      })
    })
  }, []) // ← ここに注意

  // 再実行関数を定義する
  const reload = useCallback(() => {
    fetchData().then((data) => {
      dispatch({ type: "FETCH_DATA", value: data })
    })
  })

  const data = useMemo(
    () => {
      return JSON.stringify(state.fetchedData, null, 2)
    },
    [state.fetchedData]
  )
  return { data, reload }
}

// あとは利用部分のみ
const FetchData = () => {
  const { data, reload } = useFetchDataContainer()
  return (
    <div>
      <h1>Fetch Data</h1>
      <pre>
        <code>{data}</code>
      </pre>
      <button onClick={reload}>Reload</button>
    </div>
  )
}

書き味としてはredux-thunkやもっと古い所で言えば個人的にはredialに近い形になってくる。

また、useReducerにactionをフックするような仕組みは無いため、redux-observabelredux-sagaのようなアプローチはとれないように思える。
もしそういうものが必要な場合はuseStateから拡張される形ような気がする