Hooks: combine multiple reducers when using useReducer?
Asked Answered
G

2

10

I want to use nested reducers instead of having nested switch statements (can you even do that?) in the main reducer that's passed as the first argument to useReducer. This is because my reducer function depends on more than one switch (first operation, then fruit type).

I've looked up "nested reducers" but solutions to those questions seem to all be tied to redux and combineReducers, of which there is no equivalent with Hooks.

Demo code (even though codesandbox is down again):

It doesn't actually show in the codesandbox (because the sandbox itself isn't working properly) but on my own machine I get Uncaught TypeError: fruits.apples.map is not a function after I click the Add button. However, before that, map works fine and all items are rendered as expected.

Gunshot answered 31/7, 2019 at 17:59 Comment(0)
M
14

There were some slips in your code, like class instead of className, missing key attributes. I modified your sample, have a look here.

Its also important that reducers are pure functions - always return a new state when a change is triggered by a suitable action, don't mutate the previous state (and nested properties). If no the reducer can't handle the action, just return the previous state - don't throw in the reducer, that would also make it impure.

An alternative to your shape would be to make each child reducer responsible for some sub state of the whole state tree in order to make it more scalable and composeable (Link). So one reducer for apples, one for bananas and one oranges (first fruit type, then operation). But in principle, you can handle the shape like you want/need it.

Hope, that helps out.


Update:

If you seek an Redux-like implementation of combineReducers for useReducer, also have a look at Lauri's answer, though I recommend to use a different implementation for most cases. In the following sample each reducer only gets its own part ("slice") of the state, which reduces its complexity. You also can scale this solution pretty well - just add a new property + reducer:

// combine reducers ala Redux: each can handle its own slice
const combineReducers = slices => (prevState, action) =>
  // I like to use array.reduce, you can also just write a for..in loop 
  Object.keys(slices).reduce(
    (nextState, nextProp) => ({
      ...nextState,
      [nextProp]: slices[nextProp](prevState[nextProp], action)
    }),
    prevState
  );

// atomar reducers only get either apple or orange state
const apples = (state, action) => action.type === "ADD_APPLE" ? state + 1 : state;
const oranges = (state, action) => action.type === "ADD_ORANGE" ? state + 1 : state;

const App = () => {
  const [state, dispatch] = React.useReducer(
    combineReducers({ apples, oranges }), // here we create the reducer slices
    { apples: 0, oranges: 0 }
  );
  const handleAddApple = () => dispatch({ type: "ADD_APPLE" });
  const handleAddOrange = () => dispatch({ type: "ADD_ORANGE" });

  return (
    <div>
      <p>Apples: {state.apples}, Oranges: {state.oranges}</p>
      <button onClick={handleAddApple}>add Apple</button>
      <button onClick={handleAddOrange}>add Orange</button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
Mulloy answered 31/7, 2019 at 20:4 Comment(3)
Why does combinedreducer gets called twice for the first time? No harm to result which is correct. Just curious! codesandbox.io/s/cranky-sanderson-5ewptIncur
Haven't had a look at your sandbox, but that sounds like this or this post. Otherwise feel free to create a new question.Mulloy
@helloworld. The docs say "In Strict Mode, React will call your reducer and initializer twice in order to help you find accidental impurities. This is development-only behavior and does not affect production. If your reducer and initializer are pure (as they should be), this should not affect your logic. The result from one of the calls is ignored." (Source: react.dev/reference/react/useReducer)Urena
G
16

Those who came here in search of a combineReducers() function for useReducer() hook, this might help you

const combineReducers = (...reducers: Function[]) => 
  (state: any = initialState, action: any): any => {
    for(let i=0;i<reducers.length;i++) 
      state = reducers[i](state, action)
    return state;
  }

Use this as

// At module level, so the combined function doesn't change.
const combinedReducer = combineReducers(reducer1, reducer2);

// Inside your component.
const [state, dispatch] = useReducer(combinedReducer, initialState)

Edit: I have recently taken a like for reduce function and I think it's way more clean implementation

const combineReducers = (...reducers) => 
  (state, action) => 
    reducers.reduce((newState, reducer) =>
         reducer(newState, action), state)
Grits answered 2/2, 2020 at 4:40 Comment(0)
M
14

There were some slips in your code, like class instead of className, missing key attributes. I modified your sample, have a look here.

Its also important that reducers are pure functions - always return a new state when a change is triggered by a suitable action, don't mutate the previous state (and nested properties). If no the reducer can't handle the action, just return the previous state - don't throw in the reducer, that would also make it impure.

An alternative to your shape would be to make each child reducer responsible for some sub state of the whole state tree in order to make it more scalable and composeable (Link). So one reducer for apples, one for bananas and one oranges (first fruit type, then operation). But in principle, you can handle the shape like you want/need it.

Hope, that helps out.


Update:

If you seek an Redux-like implementation of combineReducers for useReducer, also have a look at Lauri's answer, though I recommend to use a different implementation for most cases. In the following sample each reducer only gets its own part ("slice") of the state, which reduces its complexity. You also can scale this solution pretty well - just add a new property + reducer:

// combine reducers ala Redux: each can handle its own slice
const combineReducers = slices => (prevState, action) =>
  // I like to use array.reduce, you can also just write a for..in loop 
  Object.keys(slices).reduce(
    (nextState, nextProp) => ({
      ...nextState,
      [nextProp]: slices[nextProp](prevState[nextProp], action)
    }),
    prevState
  );

// atomar reducers only get either apple or orange state
const apples = (state, action) => action.type === "ADD_APPLE" ? state + 1 : state;
const oranges = (state, action) => action.type === "ADD_ORANGE" ? state + 1 : state;

const App = () => {
  const [state, dispatch] = React.useReducer(
    combineReducers({ apples, oranges }), // here we create the reducer slices
    { apples: 0, oranges: 0 }
  );
  const handleAddApple = () => dispatch({ type: "ADD_APPLE" });
  const handleAddOrange = () => dispatch({ type: "ADD_ORANGE" });

  return (
    <div>
      <p>Apples: {state.apples}, Oranges: {state.oranges}</p>
      <button onClick={handleAddApple}>add Apple</button>
      <button onClick={handleAddOrange}>add Orange</button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
Mulloy answered 31/7, 2019 at 20:4 Comment(3)
Why does combinedreducer gets called twice for the first time? No harm to result which is correct. Just curious! codesandbox.io/s/cranky-sanderson-5ewptIncur
Haven't had a look at your sandbox, but that sounds like this or this post. Otherwise feel free to create a new question.Mulloy
@helloworld. The docs say "In Strict Mode, React will call your reducer and initializer twice in order to help you find accidental impurities. This is development-only behavior and does not affect production. If your reducer and initializer are pure (as they should be), this should not affect your logic. The result from one of the calls is ignored." (Source: react.dev/reference/react/useReducer)Urena

© 2022 - 2024 — McMap. All rights reserved.