-
Notifications
You must be signed in to change notification settings - Fork 43
Creating a loop
Let's build a simple "hello world" in Mobius. We'll create a simple counter that counts up or down when we send events to the loop. We need to keep track of the current value of the counter, so we'll be using an Int
as our model, and define an enum with events for increasing and decreasing the value:
enum MyEvent {
case up
case down
}
When we get the up event, the counter should increase, and when we get the down event, it should decrease. To make the example slightly more interesting, let's say that you shouldn't be able to make the counter go negative. Let's write a simplified update function that describes this behaviour ('simplified' in the sense of not supporting Effects - we'll get back to that later!):
func update(model: Int, event: MyEvent) -> Int {
switch event {
case .up:
return model + 1
case .down:
return model > 1
? model - 1
: model
}
}
We are now ready to create the simplified loop:
let loop = Mobius.beginnerLoop(update: update).start(from: 2)
This creates a loop that starts the counter at 2. Before sending events to the loop, we need to add an observer, so that we can see how the counter changes:
loop.addObserver({ counter in print(counter) })
Observers always receive the most recent state when they are added, so this line of code causes the current value of the counter to be printed: "2".
Now we are ready to send events! Let's put in a bunch of UPs and DOWNs and see what happens:
loop.dispatchEvent(.down) // prints "1"
loop.dispatchEvent(.down) // prints "0"
loop.dispatchEvent(.down) // prints "0"
loop.dispatchEvent(.up) // prints "1"
loop.dispatchEvent(.up) // prints "2"
loop.dispatchEvent(.down) // prints "1"
Finally, you always want to clean up after yourself:
loop.dispose()
One of Mobius’s strengths is its declarative style of describing side-effects, however in our first example we had a simplified update function that didn't use any effects. Let’s expand it to show how you dispatch and handle an effect.
Let's say that we want to keep disallowing negative numbers for the counter, but now if someone tries to decrease the number to less than zero, the counter is supposed to print an error message as a side-effect.
First we need to create a type for the effects. We only have one effect right now, but let's use an enum anyway, like we did with the events:
enum MyEffect: Hashable { case reportErrorNegative }
The update function is the only thing in Mobius that triggers effects, so we need to change the signature so that it can tell us that an effect is supposed to happen. In Mobius, the Next<M, F>
class (many Mobius types are parameterised by one or more of M
, E
, and F
, for Model, Event and Effect respectively) is used to dispatch effects and apply changes to the model. Let's start by changing the return type of the update function. The Int
we have used to keep track of the current value of the counter is usually referred to as the model object in Mobius, so we change that name too.
func update(model: Int, event: MyEvent) -> Next<Int, MyEffect> {
switch event {
case .up:
return Next.next(model + 1)
case .down:
return model > 0
? Next.next(model - 1)
: Next.next(model)
}
}
Consider Next to be an object that describes "what should happen next". Therefore, the complete update function describes: "given a certain model and an event, what should happen next?" This is what we mean when we say that the code in the update function is declarative: the update function only declares what is supposed to occur, but it doesn't make it occur.
Let's now change the less-than-zero case so that instead of returning the current model, it declares that an error should be reported:
func update(model: Int, event: MyEvent) -> Next<Int, MyEffect> {
switch event {
case .up:
return Next.next(model + 1)
case .down:
return model > 0
? Next.next(model - 1)
: Next.next(model, effects: [.reportErrorNegative])
}
}
For the sake of readability you should statically import/rely on type inference for the methods on Next and Effects, so let's go ahead and do that:
func update(model: Int, event: MyEvent) -> Next<Int, MyEffect> {
switch event {
case .up:
return .next(model + 1)
case .down:
return model > 0
? .next(model - 1)
: .next(model, effects: [.reportErrorNegative])
}
}
That's it for the update function!
Since we now have an effect, we need an Effect handler. When an Update function dispatches Effects, Mobius will automatically forward them to the Effect handler. It executes the Effects, making the declared things happen. An Effect Handler can be thought of as a loop segment that connects the Effect-dispatching part of the Update function with the Event-receiving part. An Effect Handler is a function from a Consumer<Event>
- the place where it should put generated Events - to a Connection<Effect>
- the place where Mobius should put Effects, and where it can request shutdown.
The basic shape looks like this:
final class EffectHandler: Connectable {
typealias InputType = MyEffect
typealias OutputType = MyEvent
func connect(_ eventConsumer: @escaping Consumer<MyEvent>) -> Connection<MyEffect> {
return Connection<MyEffect>(
acceptClosure: { (effect: MyEffect) in
// ...
},
disposeClosure: {
// ...
}
)
}
}
If you're used to Observables
, this may look backwards. It's because Mobius uses Consumers
that you push things to rather than Observables that you receive things from.
The effect handler gets connected to the loop by the framework when the loop starts. When connecting, the handler must create a new Connection
that Mobius uses to send Effect objects to the Effect handler. The Event consumer is used for sending events back to the update function, however it is important that the handler respects the dispose()
call. This means that when dispose()
is called, no more events may be sent to the event consumer. Furthermore, any resources associated with the connection should be released when the connection gets disposed.
In this case we have a very simple effect handler that doesn’t emit any events and therefore ignores the eventConsumer
:
final class EffectHandler: Connectable {
typealias InputType = MyEffect
typealias OutputType = MyEvent
func connect(_ eventConsumer: @escaping Consumer<MyEvent>) -> Connection<MyEffect> {
return Connection<MyEffect>(
acceptClosure: { (effect: MyEffect) in
print("error!")
},
disposeClosure: {
// We don't have any resources to release, so we can leave this empty.
}
)
}
}
In order to avoid having to explicitly naming your model/event/effect types everywhere, Mobius for Swift makes use of a LoopTypes protocol that holds all the types for a loop. Every Mobius loop needs a LoopTypes to be provided, and the typical way to define it is using an enum that conforms to the protocol:
enum MyLoopTypes: LoopTypes {
typealias Model = Int
typealias Event = MyEvent
typealias Effect = MyEffect
}
Now, armed with our new update function and effect handler, we're ready to set up the loop again:
let loop: MobiusLoop<MyLoopTypes> =
Mobius.loop(update: update, effectHandler: EffectHandler()).start(from: 2)
loop.addObserver({ counter in print(counter) })
Like last time it sets up the loop to start from "2", but this time with our new update function and an effect handler. Let's enter the same UP
s and DOWN
s as last time and see what happens:
loop.dispatchEvent(.down) // prints "1"
loop.dispatchEvent(.down) // prints "0"
loop.dispatchEvent(.down) // prints "0", followed by "error!"
loop.dispatchEvent(.up) // prints "1"
loop.dispatchEvent(.up) // prints "2"
loop.dispatchEvent(.down) // prints "1"
It prints the new error message, and we see that it still prints a zero. However, we would like to get only the error message, and not the current value of the counter. Fortunately Next
has the following four static factory methods:
Model changed | Model unchanged | |
---|---|---|
Effects | Next.next(model, effects) | Next.dispatch(effects) |
No Effects | Next.next(model) | Next.noChange |
This enables us to say either that nothing should happen (no new model, no effects) or that we only want to dispatch some effects (no new model, but some effects). To do this you use Next.noChange()
or Next.dispatch(effects(...))
respectively. We don't make any changes to the model in the less-than-zero case, so let's change the update function to use dispatch(effects(...))
:
func update(model: Int, event: MyEvent) -> Next<Int, MyEffect> {
switch event {
case .up:
return .next(model + 1)
case .down:
return model > 0
? .next(model - 1)
: .dispatchEffects([.reportErrorNegative])
}
}
Now let's send our events again:
loop.dispatchEvent(.down) // prints "1"
loop.dispatchEvent(.down) // prints "0"
loop.dispatchEvent(.down) // prints "error!"
loop.dispatchEvent(.up) // prints "1"
loop.dispatchEvent(.up) // prints "2"
loop.dispatchEvent(.down) // prints "1"
Success!
In this case we merely printed the error to the screen, but you can imagine the effect handler doing something more sophisticated, maybe flashing a light, playing a sound effect, or reporting the error to a server.
Getting Started
Reference Guide
Patterns