-
Notifications
You must be signed in to change notification settings - Fork 42
Effect Handler
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.
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.
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.
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.
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:
-
.to { parameters in ... }
- A fire-and-forget style function that takes the effect parameters as its argument. -
.toEvent { parameters in ... }
A function which takes the effect parameters as its argument and returns an optional event to send back into the loop. -
.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. -
.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.
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 callback.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 callback.end(with: .usersFetched(users)) }
return AnonymousDisposable { request.cancel() }
}
.asConnectable
You can specify a DispatchQueue
to run an effect on by using .routeCase(...).on(queue:)
.
For example, to handle an effect on the main queue, you can write:
EffectRouter<Effect, Event>()
.routeCase(.myUIEffect)
.on(queue: .main)
.to {
// Call some code that needs to run on the main thread
}
Note: sending events back to the loop is not thread-safe unless your loop is running in a MobiusController
! See: Using MobiusController.
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, send
ing and end
ing. 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. Send
ing after end
ing 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.
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 Connectable
s 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.
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.
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.
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.
An effect handler takes Effect
s as input, optionally produces Event
s 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.
Getting Started
Reference Guide
Patterns