loading...

Emulate Redux with React hooks

terrierscript profile image terrierscript ・4 min read

Notice: React Hooks is RFC. This article is experimental

1. use combineReducers with useReducer

We can create nested reducer in redux combineReducer and I try to combine nested reducer and useReducer hook.

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,
  // nest
  someNested: combineReducers({
    inputValue
  })
})

And create components

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>
  )
}

I can got good result when pass dummy initialState(=undefined) and any dummy action.

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

2: Create Provider with createContext and useContext

We can avoid passing props with context.

https://reactjs.org/docs/hooks-faq.html#how-to-avoid-passing-callbacks-down

const ReducerContext = createContext()

// Wrap Context.Provider
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>
  )
}

For consumer, we can use 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>
  )
}

If you want use <Consumer>, like this.


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. Emulate bindActionCreactors with useCallback

If we want bind action, we can use useCallback

  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. Emulate mapStateToProps and reselect with useMemo

const InputValue = () => {
  const { state, dispatch } = useContext(ReducerContext)
  // memolized. revoke if change state.someNested.inputValue 
  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. Emulate Container


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>
  )
}

// 
const useInputContainer = () => {
  const { state, dispatch } = useContext(ReducerContext)
  // memolized action dispatcher
  const updateValue = useCallback(
    (e) =>
      dispatch({
        type: "UPDATE_VALUE",
        value: e.target.value
      }),
    [dispatch]
  )
  // memolized value
  const inputValue = useMemo(() => state.someNested.inputValue, [
    state.someNested.inputValue
  ])
  return {
    updateValue, inputValue
  }
}

const InputValue = () => {
  const { updateValue, inputValue } = useInputContainer()
  return (
    <div>
      <h1>Input foo</h1>
      <div>value: {inputValue}</div>
      <input value={inputValue} onChange={updateValue} />
    </div>
  )
}

Example code

https://stackblitz.com/edit/github-hgrund?file=src/App.js

Extra: middleware

Extra-1: Async fetch

We can emulate middleware with useEffect,But this may not recommended and we wait for Suspence


Reducer

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

We create async function return random value.

const fetchData = (dispatch) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ random: Math.random() })
    }, 100)
  })
  // Really:
  // return fetch("./async.json")
  //   .then((res) => res.json())
  //   .then((data) => {
  //     return data
  //   })
}

Container:
We want pass useEffect to empty array([]).
https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

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

  // call on mount only
  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>
  )
}

Extra-2: Emulate custom middleware (like applyMiddleware)

If we need reducer middleware, we can wrap reducer dispatch

// my custom middleware
const myMiddleware = (state, dispatch) => {
  return (action) => {
    if (action.type == "OOPS") { // fire action when `OOPS` action.
      dispatch({ type: "SET_COUNT", value: state.counter + 100 })
    }
  }
}

const useEnhancedReducer = (reducer, enhancer) => {
  const [state, baseDispatch] = useReducer(reducer, undefined, {
    type: "DUMMY_INIT"
  })

  const next = useMemo(() => enhancer(state, baseDispatch), [
    state,
    baseDispatch
  ])

  // wrapped dispatch
  const dispatch = useCallback((action) => {
    baseDispatch(action)
    next(action)
  })

  return { state, dispatch }
}

const Provider = ({ children }) => {
  const { state, dispatch } = useEnhancedReducer(rootReducer, myMiddleware)
  const value = { state, dispatch }
  return (
    <ReducerContext.Provider value={value}>{children}</ReducerContext.Provider>
  )
}

Discussion

markdown guide
 

for the emulating reselect

const { state, dispatch } = useContext(ReducerContext);
const counter = useMemo(() => state.counter, [state.counter])

how to avoid re rendering every time the state is changed only when the particular slice of the state is changed?

 

Oh, I did not know about that behavior.I will try to find out about it.