aesterisk_
aesterisk_

Reputation: 359

De-duplicating redux action logic with high order reducers

I'm refactoring a React/Redux app that has been built to a point where there is a need to de-duplicate a lot of reducer logic. I've looked into High Order Reducers but I'm having a hard time wrapping my head around how I can get them to work for me.

Here's an example:

reducers.js

import components from './entities/components';
import events from './entities/events';
import items from './entities/items';

const entities = combineReducers({
  components,
  items,
  events,
});

I'm using normalizr. Here's an idea of what my state looks like. For this example, just know that events can belong to components or items, but the two are otherwise distinguishable.

{
  entities: {
    components: {
      component_1: {
        events: ['event_1'],
      },
    },
    items: {
      item_1: {
        events: ['event_2'],
      },
    },
    events: {
      event_1: {
        comment:  "foo"
      },
      event_2: {
        comment:  "bar"
      }
    }
  }
}

I'm using the EVENT_ADD action type in all 3 reducers. The logic is identical for component.js and item.js (below), but the two also have independent actions that are specific to their type.

Here, I got around unnecessarily adding the event to the wrong place by using Immutable.js's .has() function to check if the target ID exists in that part of the state.

export default (state = getInitState('component'), action) => {
  switch (action.type) {
    case EVENT_ADD:
      return state.has(action.targetId) ?
        state.updateIn(
          [action.targetId, 'events'],
          arr => arr.push(action.event.id)
        ) :
        state;
        
    [...]
  }
}

In event.js, I use EVENT_ADD here as well to add the contents of an event to that portion of the state.

export default (state = getInitState('events'), action) => {
  switch (action.type) {
    case EVENT_ADD:
      return state.set(action.event.id, fromJS(action.event));

    [...]
  }
}

My understanding of high order reducers is that I need to do one or both of the following:

  1. Use separate action types ADD_EVENT_${entity} (thus making me use both EVENT_ADD_COMPONENT and EVENT_ADD_ITEM in event.js to create an event regardless of its parent.)
  2. Filter by name in my main reducer, as shown at the bottom of the docs, in which case, I'm not sure how to to differentiate my initialState and actions that are specific to a component and item if I'm just sharing a reducer for both.

It seems like I want some sharedReducer.js where I can share logic between components and items for actions like ADD_EVENT, DELETE_EVET, etc. And then also have two independent reducers for each entity. I'm unsure how I can accomplish this or what design pattern is best.

Any guidance would be appreciated. Thank you!

Upvotes: 4

Views: 1574

Answers (2)

markerikson
markerikson

Reputation: 67499

The phrase "higher-order $THING" generally means "a function that takes a $THING as an argument, and/or returns a new $THING" (where "$THING" is usually "function", "component", "reducer", etc). So, a "higher-order reducer" is a function that takes some arguments, and returns a reducer.

Per the Structuring Reducers - Reusing Reducer Logic section you linked, the most common higher-order reducer example is a function that takes some kind of distinguishing value like an ID or action type or substring, and returns a new reducer function that uses that distinguishing value to know when it's actually supposed to respond. That way, you can create multiple instances of the same function, and only the "right" one will respond to a given action even though the rest of the logic is the same.

It seems like you're generally on the right track already, and there's a few ways you could possibly organize the shared logic.

One approach might be to explicitly look for the "common" actions first and run that reducer, and then look for the specific cases:

export default (state = getInitState('events'), action) => {
    // First check for common cases
    switch(action.type) {
        case EVENT_ADD:
        case OTHER_COMMON_ACTION: {
            state = sharedReducer(state, action);
            break;
        }        
    }
    
    switch(action.type) {
        case EVENT_ADD:
        case EVENTS_SPECIFIC_ACTION: {
            return eventsSpecificReducer(state, action);
        }
    }
    
    return state;
}

Another approach might be to use something like the reduce-reducers utility, as shown in the Structuring Reducers - Beyond combineReducers section:

const entities = combineReducers({
    components : reduceReducers(componentsReducer, sharedReducer),
    items : reduceReducers(itemsReducer, sharedReducer)
    events,
});

Hopefully that gives you some pointers in the right direction. If you've got more questions, feel free to ask here, or in the Reactiflux chat channels on Discord - invite link is at https://www.reactiflux.com. (Note: I'm a Redux maintainer, author of that "Structuring Reducers" docs section, and an admin on Reactiflux.)

Upvotes: 5

Miloš Rašić
Miloš Rašić

Reputation: 2279

I've had a similar problem wrapping my head around higher order reducers. Even with experience writing higher order functions and components, there's a lot of constraints with reducers that make it more difficult.

I came up with a solution like this:

export default function withSomething(reducer) {
    const initialState = {
        something: null,
        ...reducer(undefined, {}),
    };

    return function (state = initialState, action) {
        let newState = state;

        switch (action.type) {
            case actionTypes.SET_SOMETHING:
                newState = {
                    ...state,
                    something: action.something,
                };
                break;
            default:
        }

        return reducer(newState, action);
    };
}

The main "tricks" are calling wrapper reducer with undefined state to get its initial state and add properties to it, and passing the new state that results from HOR's action handling to the wrapped reducer. You could optimize by not calling the wrapped reducer if newState != state after the switch construct, but I feel this way is more flexible because it potentially lets the wrapped reducer handle and action that has already been handled and do something with its own part of the state.

Upvotes: 0

Related Questions