この投稿は別サイトからのクロス投稿です(クロス元:https://qiita.com/terrierscript/...

React HooksはRFCの段階です。この記事はあくまで実験の産物としてお読み下さい。

準備

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

yarn add [email protected] [email protected]

yarn add [email protected]とかでも選択が出来る)

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を作成。初期値は空

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から拡張される形ような気がする

関連記事

この記事へのコメント

まだコメントはありません
+1
54
@terrierの技術ブログ
このエントリーをはてなブックマークに追加