Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Composable Reducers using Lenses and Prisms #171

Closed
wants to merge 11 commits into from
181 changes: 181 additions & 0 deletions Cookbook/Proposals/ComposableReducers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Composable Reducers using Lenses and Prisms

* Author: Yasuhiro Inami
* Review Manager: David Rodrigues

## Introduction

This proposal adds functional `Lens` and `Prism` ideas to break down `reducer` / `state` / `event` (`action`) in [ReactiveFeedback](https://github.com/Babylonpartners/ReactiveFeedback) so that we can create a large application from small component compositions.

inamiy marked this conversation as resolved.
Show resolved Hide resolved
## Motivation

In Babylon.app, we are using [ReactiveFeedback](https://github.com/Babylonpartners/ReactiveFeedback) to control states, side-effects, and feedback loops to define a particular screen (view controller) behavior, owned by `ViewModel`.

However, this `ViewModel` can easily become too complex as `reducer: State -> Event -> State` grows large.
inamiy marked this conversation as resolved.
Show resolved Hide resolved

Unfortunately, splitting into multiple `ViewModel`s is not a clever solution, as managing multiple `ReactiveFeedback`s that interact with each other tends to be hard to control.
inamiy marked this conversation as resolved.
Show resolved Hide resolved

To curcumvent this problem, we instead **split `reducer`s and also `state`s and `event`s**, and then combine them using `Lens` and `Prism`.

The basic idea can be found in @mbrandonw ’s talk:

[Brandon Williams \- Composable Reducers & Effects Systems \- YouTube](https://www.youtube.com/watch?v=QOIigosUNGU)

## Proposed solution

### Functional `Lens` & `Prism`

```swift
/// e.g. A = whole struct, B = partial struct
struct Lens<A, B> {
let get: (A) -> B
let set: (A, B) -> A
}

/// e.g. A = one of enum case, B = its associated value
struct Prism<A, B> {
let preview: (A) -> B? // simplified from `A -> Either<A, B>`, a dual of `set`
let review: (B) -> A // a dual of `get`
}
```
inamiy marked this conversation as resolved.
Show resolved Hide resolved

- `Lens` is a pair of getter and setter (similar to `WritableKeyPath<A, B>`, but more composable)
- `Prism` is a dual of `Lens`, reversing its arrows
inamiy marked this conversation as resolved.
Show resolved Hide resolved

While `Lens` is useful for traversing `struct` members, `Prism` is useful for traversing `enum` cases.
Because in ReactiveFeedback, `State` is normally defined as `struct` and `Event` is `enum`, we need both features to be able to transform `reducer` into arbitrary structure.

### `Reducer`

```swift
struct Reducer<Action, State>: Monoid {
inamiy marked this conversation as resolved.
Show resolved Hide resolved
let reduce: (Action, State) -> State

init(_ reduce: @escaping (Action, State) -> State) {
self.reduce = reduce
}

/// Zero value for `+`.
static var empty: Reducer {
return Reducer { _, s in s }
}

// Append operator, just like `+`.
static func <> (lhs: Reducer, rhs: Reducer) -> Reducer {
return Reducer { action, state in
rhs.reduce(action, lhs.reduce(action, state))
}
}
}
```

`Reducer` is a wrapper type around `reduce: (Action, State) -> State` function that conforms to `Monoid` (has "zero" and "+") to combine 2 reducers into one.

By using this `append`ing capability, we can create more complex `Reducer` from splitted `SubReducer`s.

### `Reducer` lifting from `SubState` / `SubAction`

However, `SubReducer`s don't normally have the same type with the others, even with the `(Main)Reducer` type.

For example,

- Main screen: `MainReducer = Reducer<MainAction, MainState>`
- Component 1: `SubReducer1 = Reducer<Sub1Action, Sub1State>`
- Component 2: `SubReducer2 = Reducer<Sub2Action, Sub2State>`
- ...

To convert `SubReducer1` and `SubReducer2` types into `MainReducer` (so that they can be combined using `<>`), we can use the following `lift` functions:

```swift
extension Reducer {
/// `Reducer<Action, SubState> -> `Reducer<Action, State>`
func lift<SuperState>(state lens: Lens<SuperState, State>) -> Reducer<Action, SuperState> {
return Reducer<Action, SuperState> { action, superState in
lens.set(superState, self.reduce(action, lens.get(superState)))
inamiy marked this conversation as resolved.
Show resolved Hide resolved
}
}

/// `Reducer<SubAction, State> -> `Reducer<Action, State>`
func lift<SuperAction>(action prism: Prism<SuperAction, Action>) -> Reducer<SuperAction, State> {
return Reducer<SuperAction, State> { superAction, state in
guard let action = prism.preview(superAction) else { return state }
return self.reduce(action, state)
}
}
}
```

In short, to bring each small reducers to the same level and combine, we need `lift`.

And to `lift`, we need `Lens` and `Prism`.

## Example

```swift
// MARK: - Component 1 (isolated from Main & Component 2)
//---------------------------------------------------------

enum Sub1Action {
case increment
case decrement
}

struct Sub1State {
var count: Int = 0
}

let subReducer1 = Reducer<Sub1Action, Sub1State> { action, state in
switch action {
case .increment: return state.with { $0.count + 1 }
case .decrement: return state.with { $0.count - 1 }
}
}

// MARK: - Component 2 (isolated from Main & Component 1)
//---------------------------------------------------------

enum Sub2Action { ... }
struct Sub2State { ... }
let subReducer2: Reducer<Sub2Action, Sub2State> = ...

// MARK: - Main
//---------------------------------------------------------

enum MainAction {
case sub1(Sub1Action)
case sub2(Sub2Action)

enum prism { // NOTE: Can codegen
static let sub1Action = Prism<MainAction, Sub1Action> = ...
inamiy marked this conversation as resolved.
Show resolved Hide resolved
static let sub2Action = Prism<MainAction, Sub2Action> = ...
}
}

struct MainState {
var sub1: Sub1State
var sub2: Sub2State
// var shared: ... /* NOTE: Shared state can belong to here */

enum lens { // NOTE: Can codegen
static let sub1State = Prism<MainState, Sub1State> = ...
inamiy marked this conversation as resolved.
Show resolved Hide resolved
static let sub2State = Prism<MainState, Sub2State> = ...
}
}

let mainReducer: Reducer<MainAction, MainState> =
Copy link
Contributor

@ilyapuchka ilyapuchka Jul 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this what will happen with the mainReducer? It will call the sub reducers to take care of their respective parts and then translate their results into the MainState (which is yet to be defined by the lens/prism implementations), right?

Is it different essentially from breaking reducer into smaller functions?

func reducer(state: State, event: Event) -> Event {
  switch event {
    case .sub1(sub1Event):
      return state.set(\.sub1, reduceSub1(state: state.sub1, event: sub1Event))
    ...
  }
}

func reduceSub1(state: Sub1State, event: Sub1Action) -> Sub1State {
  switch event { ... }
}

Maybe seeing how this will fit into splitting view models (if it will) and with feedbacks will make this difference more appealing than for just reducers on their own?
Comparing two different approaches side by side will also probably make the difference more visual.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By introducing "Composable Reducers", we don't have to write reducer (main reducer) pattern-matching, which we don't want to repeat things over and over again.

I also added "Composable Feedbacks" example in 4db5676 , so it becomes more apparent if we rewrite mainFeedback by hand.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd emphasise even more this point in the proposal itself, being one of the most powerful advantages of FP compositionality. 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But don't we write the same pattern matching in the prisms implementations? =) It might be a bit easier to code generate, but until it is in place we'll have to write this boilerplate 🤷‍♂ I like how prisms look simple and separate concerns and I used them (in a bit different form) myself, but not sure what boilerplate will be more clear.
P.S. I'm 💯 into code generating this

Copy link
Contributor Author

@inamiy inamiy Jul 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilyapuchka
Pattern-matching in small Prism layer and making its composition is more FP way than pattern-matching whole gigantic enum at once filled with un-reusable code.

It becomes more and more apparent once we have n-layered reducers / feedbacks.
(Though it may sound unrealistic, IMO we should be ready for such scalable architecture)

And I totally agree that this proposal gains more popularity once we introduce codegen.
But even without it, I think it's worth introducing Lens / Prism for scalability.

I personally can't live without these features to split large codebase.
(Currently ongoing in https://github.com/Babylonpartners/babylon-ios/pull/7938 )
And unfortunately, current Swift KeyPath can't solve this problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all into splitting huge switch into smaller pieces, that should be probably emphasised in the proposal as a win with pros and cons of each approach (I'm not yet sold thought that it will improve reusability on practice due to how types depend on each other in our code base) 👍
I think it will worth also adding your thoughts about why KeyPaths don't fit in the proposal, good candidate for Alternatives Considered section 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

47bde8b Added composition example to explain the simpler solution.

sub1
.lift(action: MainAction.prism.sub1Action)
.lift(state: MainState.lens.sub1State)
<>
sub2
.lift(action: MainAction.prism.sub2Action)
.lift(state: MainState.prism.sub2State)
inamiy marked this conversation as resolved.
Show resolved Hide resolved
```

## Impact on existing codebase

This proposal will affect all construction of `ViewModel` and `RAF`, but we can apply modification little by little.

## Alternatives considered

N/A
inamiy marked this conversation as resolved.
Show resolved Hide resolved