Skip to content

Instantly share code, notes, and snippets.

@yelouafi
Last active June 14, 2019 15:48
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yelouafi/80c3aad03897a55143738c2f458dbed7 to your computer and use it in GitHub Desktop.
Save yelouafi/80c3aad03897a55143738c2f458dbed7 to your computer and use it in GitHub Desktop.

Motivation

In Redux, reducer composition with combineReducers offers a powerful way to handle complex update logic of an application. A reducer can encapsulate all the ways a part of the state is mutated because it can react to multiple types of actions.

But in some cases there is also a need for another type of factoring: often the update logic is simple (for example setting a single value), and the there are many places in the state shape where the update logic is the same.

For example, let's take (the deadly boring) counter demo

import { createStore } from 'redux'

function counter(state = 0, action) {
  switch(action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

const store = createStore(counter)

store.dispatch({type: 'INCREMENT'})
// => state = 1

Now suppose our application requires 2 counters

import { createStore, combineReducers } from 'redux'

function counter(state = 0, action) {...}

const reducer = combineReducers({
  first: counter,
  second: counter
})

const store = createStore(reducer)

We want to increment only one counter (eg: the first counter). But we can't simply dispatch an INCREMENT action

store.dispatch(increment())
// state = {first: 1, second: 1}

BAD! both counters get incremented.

This is because of how combineReducers works: It dispatches the actions to all child reducers. combineReducers works in a Brodcast mode: It propagate the message (action) to all reducer tree owend by it.

First solution

One solution is to use namespacing. An action destined to a specific part in the state will have a namespace property. And a reducer in that path will react only to actions whose path match its own path.

We're going to write a Higher Order Reducer which converts a reducer to a namespaced one

function wrapReducerWithNS(reducer, namespace) {
  return function namespaced(state, action) {
    // propagates the INIT action
    if(state === undefined) {
      return reducer(undefined, action)
    }
    // propagates only for matching namespaces
    else if(action.namespace === namespace) {
      return reducer(state, action)
    }

    return state
  }
}

Used in our first example

import { createStore, combineReducers } from 'redux'

function counter(state = 0, action) {...}

const reducer = combineReducers({
  first: wrapReducerWithNS(counter, 'first'),
  second: wrapReducerWithNS(counter, 'second')
})

const store = createStore(reducer)

store.dispatch({type: 'INCREMENT', namespace: 'first'})
// state = {first: 1, second: 0}

Good. But we can do better

function combineReducersWithNS(reducers) {
  const reducersWithNS = {}
  Object.keys(reducers).forEach(key => {
    const reducer = reducers[key]
    reducersWithNS[key] = wrapReducerWithNS(reducer, key)
  })
  return combineReducers(reducersWithNS)
}

So we can have a more concise code

const reducer = combineReducersWithNS({
  first: counter,
  second: counter
})

But we can still do better

There are a couple of issues with the so far implemented solution

  • Handle deeply nested reducers

  • Avoid hardcoding namespaces in the actions

deeply Nested paths

Instead of a simple string, We'll use an array to represent a complete path to our reducer.

The reducer will look at the prefix path (the first item in the array) and if it matches its own namespace then it'll forward the action down to the child reducer.

function wrapReducer(reducer, ns) {
  return (state, action) => {
    if(state === undefined)
      return reducer(undefined, action)
    if(action.ns && action.ns[0] === ns) {
      return reducer(state, unwrapAction(action))
    }
    return state
  }
}

// stripes the current namespace so the action
// can be checked by the next reducer down the tree
function unwrapAction(action) {
  return {
    ...action,
    namespace: action.namespace.slice(1)
  }
}

For example, if we have

combineReducersWithNS({
  first: combineReducersWithNS({
    first: counter,
    second: counter,
  }),
  second: counter
})

Then we can increment the counter in the first/first path using

const incremntFirstOfFirst = () => ({
  type: 'INCREMENT',
  namespace: ['first', 'first']
})

Next we'll see how to remove the hard coded path dependency from the action.

introducing models

A model is pair (reducer, actions).

You can see it like an encapsulation of an isolated feature inside your application

function counter(state = 0, action) {
  switch(action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

const counterActions = {
  increment: () => ({type: 'INCREMENT'}),
  decrement: () => ({type: 'DECREMENT'})
}

const counterModel = [counter, counterActions]

We'are going to implement a combineModels function which

  • takes a pair (reducer, actions)
  • returns a pair
    • reducer: combination of namespaced reducers from child models
    • actions: an object with the same shape as model shape with namespaced actions

For example

const [reducer, actions] = combineModels({
  first: combineModels({
    first: counterModel,
    second: counterModel,
  }),
  second: counterModel
})


actions.first.first.increment()
// => {type: 'INCREMENT', namespace: ['first', 'first']}

actions.first.second.increment()
// => {type: 'INCREMENT', namespace: ['first', 'second']}

actions.second.decrement()
// => {type: 'DECREMENT', namespace: ['second']}

Implentation of combineModels

function combineModels(models) {
  const reducersWithNS = {}
  const actionsWithNS = {}

  Object.keys(models).forEach(key => {
    const [reducer, actions] = models[key]
    reducersWithNS[key] = wrapReducerWithNS(reducer, key)
    actionsWithNS[key] = wrapActions(actions, [key])
  })
  return [combineReducers(reducersWithNS), actionsWithNS]
}

function wrapActions(actions, prefix=[]) {
  const actionsWithNS = {}
  Object.keys(actions).forEach(actionKey => {
    const ac = actions[actionKey]
    if(typeof ac === 'function') {
      actionsWithNS[actionKey] = wrapActionCreator(actions[actionKey], prefix)
    } else {
      actionsWithNS[actionKey] = wrapActions(ac, prefix)
    }
  })
  return actionsWithNS
}

function wrapActionCreator(ac, ns) {
  return (...args) => {
    const action = ac(...args)
    action.ns = action.ns ? ns.concat(action.ns) : ns
    return action
  }
}

function wrapReducer(reducer, ns) {
  return (state, action) => {
    if(state === undefined)
      return reducer(undefined, action)
    if(action.ns && action.ns[0] === ns) {
      return reducer(state, unwrapAction(action))
    }
    return state
  }
}

function wrapActionCreator(ac, ns) {
  return (...args) => {
    const action = ac(...args)
    action.ns = action.ns ? ns.concat(action.ns) : ns
    return action
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment