Composable State Management: Higher-Order Reducers

Episode #71 • Aug 26, 2019 • Subscriber-Only

We will explore a form of reducer composition that will take our applications to the next level. Higher-order reducers will allow us to implement broad, cross-cutting functionality on top of our applications with very little work, and without littering our application code with unnecessary logic. And, we’ll finally answer “what’s the point?!”

Higher-Order Reducers
Introduction
00:06
What’s a higher-order reducer?
01:56
Higher-order activity feeds
06:32
Higher-order logging
12:25
What’s the point?
20:09
Next Up

Modularity

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

Now that we have the basics of our architecture in place we can start to explore things to do with it that unlock capabilities that were not even possible in the old way of making the app. There is a concept that we have discussed a number of times on Point-Free known as “higher-order constructions.” This is where you take some construction that you have been studying and lift it to a higher-order by considering functions that take that object as input and return that object as output. The canonical example is “higher-order functions”, which are functions that take functions as input and return functions as output.

But on Point-Free we’ve also considered “higher-order random number generators”, which were functions that took our Gen type as input and returned the Gen type as output. And we’ve considered “higher-order parsers”, which are functions that take parsers as input and return parsers as output. Each time you form one of these higher-order constructions you gain the ability to unlock something new that the vanilla constructions could not do alone.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. Create a higher-order reducer with the following signature:

    func filterActions<Value, Action>(_ predicate: @escaping (Action) -> Bool)
      -> (@escaping (inout Value, Action) -> Void)
      -> (inout Value, Action) -> Void {
      fatalError("Unimplemented")
    }
    

    This allows you to transform any reducer into one that only listens to certain actions.

    Solution
    func filterActions<Value, Action>(_ predicate: @escaping (Action) -> Bool)
      -> (@escaping (inout Value, Action) -> Void)
      -> (inout Value, Action) -> Void {
        return { reducer in
          return { value, action in
            if predicate(action) {
              reducer(&value, action)
            }
          }
        }
    }
    
  2. Create a higher-order reducer that adds the functionality of undo to any reducer. You can start by providing new types to augment the existing state and actions of a reducer:

    struct UndoState<Value> {
      var value: Value
      var history: [Value]
      var canUndo: Bool { !self.history.isEmpty }
    }
    
    enum UndoAction<Action> {
      case action(Action)
      case undo
    }
    

    And then implement the following function to implement the functionality:

    func undo<Value, Action>(
      _ reducer: @escaping (inout Value, Action) -> Void
    ) -> (inout UndoState<Value>, UndoAction<Action>) -> Void {
      fatalError("Unimplemented")
    }
    
    Solution
    func undo<Value, Action>(
      _ reducer: @escaping (inout Value, Action) -> Void
    ) -> (inout UndoState<Value>, UndoAction<Action>) -> Void {
      return { undoState, undoAction in
        switch undoAction {
        case let .action(action):
          var currentValue = undoState.value
          reducer(&currentValue, action)
          undoState.history.append(currentValue)
        case .undo:
          guard undoState.canUndo else { return }
          undoState.value = undoState.history.removeLast()
        }
      }
    }
    
  3. Enhance the undo higher-order reducer so that it limits the size of the undo history.

    Solution
    func undo<Value, Action>(
      _ reducer: @escaping (inout Value, Action) -> Void,
      limit: Int
    ) -> (inout UndoState<Value>, UndoAction<Action>) -> Void {
      return { undoState, undoAction in
        switch undoAction {
        case let .action(action):
          var currentValue = undoState.value
          reducer(&currentValue, action)
          undoState.history.append(currentValue)
          if undoState.history.count > limit {
            undoState.history.removeFirst()
          }
        case .undo:
          guard undoState.canUndo else { return }
          undoState.value = undoState.history.removeLast()
        }
      }
    }
    
  4. Enhance the undo higher-order reducer to also allow redoing.

    Solution

    In order to keep track of redoes, the state can be modified to track what’s been undone and whether or not there are things to be redone:

    struct UndoState<Value> {
      var value: Value
      var history: [Value]
      var undone: [Value]
      var canUndo: Bool { !self.history.isEmpty }
      var canRedo: Bool { !self.undone.isEmpty }
    }
    

    Meanwhile, we need a new action to redo:

    enum UndoAction<Action> {
      case action(Action)
      case undo
      case redo
    }
    

    And finally, the higher-order reducer must handle the redo:

    func undo<Value, Action>(
      _ reducer: @escaping (inout Value, Action) -> Void,
      limit: Int
    ) -> (inout UndoState<Value>, UndoAction<Action>) -> Void {
      return { undoState, undoAction in
        switch undoAction {
        case let .action(action):
          var currentValue = undoState.value
          reducer(&currentValue, action)
          undoState.history.append(currentValue)
          undoState.undone = []
          if undoState.history.count > limit {
            undoState.history.removeFirst()
          }
        case .undo:
          guard undoState.canUndo else { return }
          undoState.undone.append(undoState.value)
          undoState.value = undoState.history.removeLast()
        case .redo:
          guard undoState.canRedo else { return }
          undoState.history.append(undoState.value)
          undoState.value = undoState.undone.removeFirst()
        }
      }
    }
    
  5. Add undo and redo buttons to the CounterView, and make them undo and redo only the counter actions on that screen.

References

Contravariance

Brandon Williams & Stephen Celis • Monday Apr 30, 2018

We first explored the concept of the pullback in our episode on “contravariance”, although back then we used a different name for the operation. The pullback is an instrumental form of composition that arises in certain situations, and can often be counter-intuitive at first sight.

Let’s explore a type of composition that defies our intuitions. It appears to go in the opposite direction than we are used to. We’ll show that this composition is completely natural, hiding right in plain sight, and in fact related to the Liskov Substitution Principle.

Elm: A delightful language for reliable webapps

Elm is both a pure functional language and framework for creating web applications in a declarative fashion. It was instrumental in pushing functional programming ideas into the mainstream, and demonstrating how an application could be represented by a simple pure function from state and actions to state.

Redux: A predictable state container for JavaScript apps.

The idea of modeling an application’s architecture on simple reducer functions was popularized by Redux, a state management library for React, which in turn took a lot of inspiration from Elm.

Composable Reducers

Brandon Williams • Tuesday Oct 10, 2017

A talk that Brandon gave at the 2017 Functional Swift conference in Berlin. The talk contains a brief account of many of the ideas covered in our series of episodes on “Composable State Management”.