The power of

higher-order reducers

Daniel Bugl (omnidan)

whoami

  • Daniel Bugl (omnidan)

 

 

Re... ducks?

  • reducer = (state, action) => state
  • react-redux
    • inject state and action creators as props
    • when state changes, re-render
const ADD_TODO = 'ADD_TODO'
{
  type: ADD_TODO,
  text: 'Create example action for talk'
}
// a typical redux action

What is this talk about?

from redux-undo's README.md:

The problems

const initialState = {
  past: [],
  present: null, // (?) How do we initialize the present?
  future: []
}

function undoable(state = initialState, action) {
  const { past, present, future } = state

  switch (action.type) {
    case 'UNDO':
      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)
      return {
        past: newPast,
        present: previous,
        future: [ present, ...future ]
      }
    case 'REDO':
      const next = future[0]
      const newFuture = future.slice(1)
      return {
        past: [ ...past, present ],
        present: next,
        future: newFuture
      }
    default:
      // (?) How do we handle other actions?
      return state
  }
}

The problems

  • Problem 1: Where do we get the initial present state from? We don’t seem to know it beforehand.

 

  • Problem 2: How do we actually delegate the control over the present state to a custom reducer?

 

  • Problem 3: Where do we react to the external actions to save the present to the past?

The solution

  • Reducer Enhancers / Higher-order Reducers

 

  • are higher-order functions
    functions that return functions (or take one or more functions as arguments)
// `createLogger`, a higher-order function (that returns another function)
function createLogger(name) {
  return function logger(message) {
    // this function can access `name` from the upper scope!
    console.log(name + ': ' + message)
  }
}

const log = createLogger('redux-undo')
log('undo action received') // prints out `redux-undo: undo action received`

The solution

  • the same is possible with reducers
  • higher-order reducers
           reducers that return reducers (and take a reducer function as argument)
// `doNothingWith`, a higher-order reducer (that does nothing at all!)
function doNothingWith(reducer) {
  return function (state, action) {
    // Just call the passed reducer
    return reducer(state, action)
  }
}
// `combineReducers`, a higher-order reducer (that combines other reducers!)
function combineReducers(reducers) {
  return function (state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      // Call every reducer with the part of the state it manages
      nextState[key] = reducers[key](state[key], action)
      return nextState
    }, {})
  }
}

This is a redux utility function: http://rackt.org/redux/docs/api/combineReducers.html

The problems

  • Problem 1: Where do we get the initial present state from? We don’t seem to know it beforehand.

 

  • Problem 2: How do we actually delegate the control over the present state to a custom reducer?

 

  • Problem 3: Where do we react to the external actions to save the present to the past?

The solution

function undoable(reducer) {
  // Call the reducer with empty action to populate the initial state
  const initialState = {
    past: [],
    present: reducer(undefined, {}), // Problem 1 solved!
    future: []
  }

  // Return a reducer that handles undo and redo
  return function (state = initialState, action) {
    const { past, present, future } = state

    switch (action.type) {
      case 'UNDO':
        // ...
      case 'REDO':
        // ...
      default:
        // Delegate handling the action to the passed reducer
        const newPresent = reducer(present, action) // Problem 2 solved!
        if (present === newPresent) {
          return state
        }
        return {
          past: [ ...past, present ], // Problem 3 solved!
          present: newPresent,
          future: []
        }
    }
  }
}

Using undoable

// This is a reducer
function todos(state = [], action) {
  /* ... */
}

// This is also a reducer!
const undoableTodos = undoable(todos)

import { createStore } from 'redux'
const store = createStore(undoableTodos)

store.dispatch({
  type: 'ADD_TODO',
  text: 'Use Redux'
})

store.dispatch({
  type: 'ADD_TODO',
  text: 'Implement Undo'
})

store.dispatch({
  type: 'UNDO'
})
  • Note: Access state with state.present if it's been wrapped with undoable

redux-undo

  • Installation:
    npm install --save redux-undo

 

  • New (1.0 beta; try it out, feedback's appreciated): 
    npm install --save redux-undo@beta

 

  • Usage:
import { combineReducers } from 'redux' // Redux utility functions
import undoable from 'redux-undo' // redux-undo higher-order reducer

const reducers = combineReducers({
  counter: undoable(counter, {
    limit: 10 // set a limit for the history
  })
})

redux-undo

  • Note: Some higher-order reducers wrap your state!
{
  todos: [...],
  filter: 'active'
}
{
  past: [...pastStatesHere...],
  present: {
    todos: [...],
    filter: 'active'
  },
  future: [...futureStatesHere...]
}

From:

reducer

To:

undoable(reducer)

redux-undo

  • Dispatching undo/redo actions:
import { ActionCreators } from 'redux-undo'

store.dispatch(ActionCreators.undo()) // undo the last action
store.dispatch(ActionCreators.redo()) // redo the last action

// jump to requested index in the past[] array
store.dispatch(ActionCreators.jumpToPast(index))

// jump to requested index in the future[] array
store.dispatch(ActionCreators.jumpToFuture(index))

redux-undo

  • Filtering actions:
undoable(reducer, {
  filter: function filterActions(action, currentState, previousState) {
    // only add to history if action is SOME_ACTION
    return action.type === SOME_ACTION
  }
})

// or you could do...

undoable(reducer, {
  filter: function filterState(action, currentState, previousState) {
    // only add to history if state changed
    return currentState !== previousState
  }
})

// or with helpers...

import undoable, { distinctState, includeAction, excludeAction } from 'redux-undo'

undoable(reducer, { filter: includeAction(SOME_ACTION) })
undoable(reducer, { filter: excludeAction(SOME_ACTION) })

undoable(reducer, { filter: includeAction([SOME_ACTION, SOME_OTHER_ACTION]) })
undoable(reducer, { filter: excludeAction([SOME_ACTION, SOME_OTHER_ACTION]) })

undoable(reducer, { filter: distinctState() })

Live demo!

Making an existing app (todos) undoable (todos-with-undo)

with redux-undo

Enjoy the superpowers!

  • Now that you know this:
    • Use higher-order functions more (really useful in some cases)
    • Try out redux-undo in your own project
    • Create your own higher-order reducer

 

  • My other projects:
    • If you ever want to ignore some actions for some reducers (e.g. for performance reasons): redux-ignore
    • Or if you want to reset the state on some actions: redux-recycle
    • node-emoji, coffea (messaging library)
    • Feel free to ask me about any of my projects!

Where to find me

I hope you learned something useful today!

Useful links

The power of higher-order reducers

By omnidan

The power of higher-order reducers

Enhance the functionality of your existing reducers by wrapping them with a higher-order reducer, like redux-undo's undoable, which adds undo/redo functionality.

  • 32,981