Skip to content

Creating a loop

Jens Ayton edited this page May 27, 2020 · 9 revisions

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(counter: Int, event: MyEvent) -> Int {
    switch event {
    case .up:
        return counter + 1
    case .down:
        return counter > 0
            ? counter - 1
            : counter
    }
}

We are now ready to create the simplified loop:

import MobiusCore
import MobiusExtras

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()

Adding Effects

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 {
    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 struct Next<Model, Effect> 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 in Mobius, so we change that name too.

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)
    }
}

Think of Next as a value 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])
    }
}

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.

There are several ways to define an Effect Handler, but for most use cases the preferred way is to use EffectRouter. This lets you describe – again, declaratively – routes from effects to objects or functions that handle those effects.

Our .reportErrorNegative is a “fire-and-forget” effect, which can be handled by a simple function from Void to Void:

func handleReportErrorNegative() {
    print("error!")
}

We can declare our single route like so:

let effectHandler = EffectRouter<MyEffect, MyEvent>()
    .routeCase(MyEffect.reportErrorNegative).to(handleReportErrorNegative)
    .asConnectable

The asConnectable property converts the effect router to a Connectable<MyEffect, MyEvent>, which is the fundamental form of an effect handler. Writing a Connectable by hand is unnecessarily cumbersome for most effect handlers, which is why we prefer EffectRouter.

Now, armed with our new update function and effect handler, we’re ready to set up the loop again:

let loop: 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 .ups and .downs 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.dispatchEffects(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 .noChange or .dispatchEffects(...) 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 dispatchEffects(...):

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.