Skip to content

Effect Handler

Jesper Sandström edited this page Apr 15, 2020 · 19 revisions

Effect Handlers receive Effects, execute them, and may produce Events as a result. A Mobius loop has a single Effect Handler, which usually is composed of individual Effect Handlers for each kind of Effect.

If the Effect handler needs data from the Model, that should always be passed along with the Effect. It would be possible for an Effect Handler to subscribe to Model updates, but that would introduce races and reduce simplicity.

Effect Handlers must never throw exceptions if something goes wrong. Doing so will crash or leave the loop in an undefined state. Instead, if an error occurs during Effect execution, that should be converted into an Event that can be used by the Update function to decide on how to proceed.

Creating Effect Handlers

The recommended way to create effect handlers is to use the EffectRouter. Using the EffectRouter effectively requires understanding how to create routes and how to construct effect handlers. We will begin with creating routes.

Routing to Effect Handlers

Effects are represented as an enum in nearly all Mobius Loops. Consider the following Effect type:

enum Effect: Equatable {
   case a
   case b(string: String)
   case c(left: String, right: String)
}

It has three cases, two of which have associated values. We refer to these associated values as the effect's parameters.

NOTE: Always make sure your Effect enum conforms to Equatable.

To route to each case, you simply write:

let router = EffectRouter<Effect, Event>()
    .routeCase(Effect.a).to { ... }
    .routeCase(Effect.b).to { string in ... }
    .routeCase(Effect.c).to { (left, right) in ... }

The router will automatically extract the parameters of the cases with associated values (Effect.b and Effect.c). These parameters will be passed along to the effect handlers defined in .to(...). Since there is nothing to unwrap in the case of Effect.a, () (of type Void) is passed along as the value.

Routing to non-enums

An enum is not always the right choice for an effect handler. In these cases, you can use .routeEffects(equalTo:) and .routeEffects(withParameters:) to do largely the same types of routing. See the Swift docs for these for more information.

Handling Effects

In the previous section, routeCase(...) was used to route to each case in the Effect enum. This section will explain how to implement to .to side of things.

Effects can be handled in 4 different ways. These are ranked in order of increasing complexity - you should always use the least complicated option that still fulfils your needs:

  1. .to { parameters in ... } - A fire-and-forget style function that takes the effect parameters as its argument.
  2. .toEvent { parameters in ... } A function which takes the effect parameters as its argument and returns an optional event to send back into the loop.
  3. .to(EffectHandler) This should be used for effects which require asynchronous behavior or produce more than one event, and which have a clear definition of when an effect has been handled. For example, an effect handler which performs a network request and dispatches an event back into the loop once it is finished or if it fails. These effects handlers can be inlined using the .to { effect, callback in ... } closure syntax.
  4. .to(Connectable) This should be used for effect handlers which do not have a clear definition of when a given effect has been handled. For example, an effect handler which will continue to produce events indefinitely once it has been started.

Example

We'll assume that we have the following effects and events in our loop:

enum Effect: Equatable {
    case closeApplication
    case stopPlayback
    case fetchUsers(ids: [String])
}
enum Event {
    case playbackStopped
    case usersFetched([User])
}

Here are some examples of routing to these effects:

let effectHandler = EffectRouter<Effect, Event>()
    .routeCase(Effect.closeApplication)
        .to { closeApplication() }
     .routeCase(Effect.stopPlayback)
        .toEvent {
           player.stopPlayback()
           return Event.playbackStopped
        }
    .routeCase(Effect.fetchUsersWithIDs)
        .to(UserFetcher(dataSource: dataSource)) // defined below
    .asConnectable

REMINDER: Always make sure your Effect enum conforms to Equatable.

Let's define the UserFetcher (mentioned above):

struct UserFetcher: EffectHandler {
    let dataSource: DataSource

    func handle(
        _ userIDs: [String],
        _ callback: EffectCallback<Event>
    ) -> Disposable {
        let request = dataSource
            .downloadUsers(ids: userIDs)
            .then { users in response.end(with: .usersFetched(users)) }

        return AnonymousDisposable { request.cancel() }
    }
}

UserFetcher is a relatively small struct. If we want, we can use a convenience function on the EffectRouter to inline the entire implementation. With this change, the new router looks like this:

let effectHandler = EffectRouter<Effect, Event>()
    .routeCase(Effect.closeApplication)
        .to { closeApplication() }
    .routeCase(Effect.stopPlayback)
        .toEvent {
           player.stopPlayback()
           return Event.playbackStopped
        }
    .routeCase(Effect.fetchUsersWithIDs)
        .to { userIDs, response in
            let request = dataSource
                .downloadUsers(ids: userIDs)
                .then { users in response.end(with: .usersFetched(users)) }

            return AnonymousDisposable { request.cancel() }
        }
    .asConnectable

Understanding EffectCallback

The EffectHandler protocol defines a handle function which takes an EffectCallback<Event> as its second parameter. This callback is what you use to communicate with the loop. It supports two types of operations, sending and ending. These can be called from any thread. Once any variant of end has been called on the callback, all operations (from any thread) on it will be no-ops. Sending after ending could be viewed as a programmer error, but we've decided not to crash in this case since this would require considerably more locking to be done on the consumer side of the API.

Tip: EffectCallback's end(with: ) function lets you send a number of effects and end in sequence.

Routing to Connectables

The 4th type of routing target, .to(Connectable) has not yet been described. Connectable was previously the main way of implementing asynchronous effect handlers in Mobius. Now, EffectHandler is almost always preferable.

There is still a small number of cases where Connectables are the right choice: effect handlers that do not have a clear relationship between their effects and events. If you cannot determine a reasonable time to call callback.end(), you are probably in such a case.

EffectRouter Warning

The EffectRouter requires exactly one route to be registered for each effect it receives. Handling an effect in more than one route, or in no routes, will result in a runtime error.

Effect Handler Philosophy

Like most things in programming, the best effect handlers are small and have a single focus. They should be simple to understand and reason about. One rule of thumb is to create roughly one effect handler per Effect in your loop. Keep in mind though that this is not strictly necessary, or even necessarily always a good idea. Take the time to MoFlow out your effects and determine the level of granularity which makes sense in your context.

Tips and Tricks

Group similar effects by nesting your enums. Instead of:

enum Effect {
    playSong
    pauseSong
    resumeSong
    navigateToArtistPage
    navigateToHomePage
    navigateToSearchPage
}

Prefer:

enum Effect {
    song(Playback)
    navigateTo(NavigationTarget)
}
enum Playback {
    case play
    case pause
    case resume
}
enum NavigationTarget {
    case ArtistPage
    case HomePage
    case SearchPage
}

Doing this helps you decompose your domain. This way you can create two effect handlers - one for playback and one for navigation. These handlers can exhaustively switch over their respective cases.

Testing Effect Handlers

An effect handler takes Effects as input, optionally produces Events as output, and optionally carries out side-effects in our system. You will need to determine which of these to test.

If your effect handler produces events, the simplest way to test it is to treat the handler as a function from effects to events, and to write given-when-then-style tests. For example,

given:  My effect handler
when:   It receives some effect, A
then:   Expect some event B to be emitted eventually.

Unlike UpdateSpec, we currently don't provide utilities in Mobius for this style of testing effect handlers. However, the same general principles can still be applied in your tests.

Testing that side-effects are performed is more difficult, but it is fundamentally the same as testing any other side-effecting code, so the same best practices apply.

Clone this wiki locally