-
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 should handle errors by converting them into appropriate Events that can be used by the Update function to decide on how to proceed.
The recommended way to compose 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 MyEffect: 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.
To route to each case, you simply write:
let router = EffectRouter<MyEffect, MyEvent>()
.routeCase(Effect.a).to { ... }
.routeCase(Effect.b).to { string in ... }
.routeCase(Effect.c).to { (left, right) in ... }
Note: If an effect has an associated value, you must add the Effect.
prefix in .routeCase
. Otherwise you will get misleading compiler errors. It is good practice to always include the prefix for this reason.
Note: EffectRouter works best if your Effect enum conforms to Equatable
.
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.
Although we recommend enum
s for Effects, it might not always be the right choice. In other 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. These functions cannot send events back to the loop. -
.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 events and effects in our loop:
enum MyEvent {
case playbackStopped
case usersFetched([User])
}
enum MyEffect: Equatable {
case closeApplication
case stopPlayback
case fetchUsers(ids: [String])
}
Here are some examples of routing to these effects:
let effectHandler = EffectRouter<MyEffect, MyEvent>()
.routeCase(MyEffect.closeApplication)
.to { closeApplication() }
.routeCase(MyEffect.stopPlayback)
.toEvent {
player.stopPlayback()
return MyEvent.playbackStopped
}
.routeCase(MyEffect.fetchUsersWithIDs)
.to(UserFetcher(dataSource: dataSource)) // defined below
.asConnectable
Note: The closeApplication
and stopApplication
cases will only work if MyEffect
conforms to Equatable
.
Let’s define the UserFetcher
(mentioned above):
struct UserFetcher: EffectHandler {
let dataSource: DataSource
func handle(
_ userIDs: [String],
_ callback: EffectCallback<MyEvent>
) -> 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<MyEffect, MyEvent>()
.routeCase(Effect.closeApplication)
.to { closeApplication() }
.routeCase(MyEffect.stopPlayback)
.toEvent {
player.stopPlayback()
return MyEvent.playbackStopped
}
.routeCase(MyEffect.fetchUsersWithIDs)
.to { userIDs, callback in
let request = dataSource
.downloadUsers(ids: userIDs)
.then { users in callback.end(with: .usersFetched(users)) }
return AnonymousDisposable { request.cancel() }
}
.asConnectable
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.
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.
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<MyEffect, MyEvent>()
.routeCase(.myUIEffect)
.on(queue: .main)
.to {
// Call some code that needs to run on the main thread
}
If you’re using a MobiusController, the default queue is a queue owned by the controller, and events sent back to the loop will automatically be marshalled to the right queue. If you’re using a “raw” MobiusLoop
, the default queue is whichever one you called loop.dispatchEvent
on, and ensuring events are sent back to the right queue is your own responsibility. See Using MobiusController for more information.
Warning: Note in particular that .on(queue:)
cannot safely be combined to with .toEvent
or .to(EffectHandler)
for raw MobiusLoop
s, unless the target queue is the same queue that events will be dispatched on (in which case it will make the effect handler run asynchronously).
It’s often useful to group similar effects by nesting 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 artist
case home
case search
}
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.
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.
Implementing a Connectable
can be done by conforming to the Connectable
protocol, or inheriting from ConnectableClass
in MobiusExtras
.
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.
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