Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Model an App

Start with the domain. Do not start with the runtime.

The example app in this repository has an account module and a counter module. Each module follows the same small shape:

  • an Event enum for inputs;
  • private model state;
  • a ViewModel for rendering;
  • an async update;
  • a synchronous view.

The counter is the smallest example:

#![allow(unused)]
fn main() {
pub enum Event {
    Increase,
    Decrease,
    Random,
}

pub struct ViewModel {
    pub counter: String,
}
}

The implementation holds state and the capabilities it needs:

#![allow(unused)]
fn main() {
pub struct App {
    model: RwLock<Model>,
    random: Arc<dyn Random>,
    render: Arc<dyn Render>,
    logger: Arc<dyn Logger>,
}
}

The update function is plain async Rust:

#![allow(unused)]
fn main() {
pub async fn update(&self, event: Event) {
    match event {
        Event::Increase => {
            self.model.write().counter += 1;
        }
        Event::Decrease => {
            self.model.write().counter -= 1;
        }
        Event::Random => {
            let num = self.random.get_number().await;
            self.model.write().counter += num;
        }
    }

    self.logger
        .log(format!("counter={}", self.view().counter))
        .await;
    self.render.render().await;
}
}

There is no effect queue here, no effect ID, and no runtime callback. The app calls random.get_number().await because it needs a random number. The object behind Random decides whether that call is served by a test channel, a TUI handler, an FFI host, or a real implementation.

Compose Apps Directly

The root app composes child apps directly:

#![allow(unused)]
fn main() {
pub enum Event {
    Account(account::Event),
    Counter(counter::Event),
}
}

It can route events, block events, or call another capability without a special hook:

#![allow(unused)]
fn main() {
pub async fn update(&self, event: Event) {
    match event {
        Event::Account(event) => self.account.update(event).await,
        Event::Counter(event) => {
            if self.account.is_logged_in() {
                self.counter.update(event).await;
            } else {
                self.logger
                    .log("counter=blocked_until_login".to_owned())
                    .await;
                self.blocked_render.render().await;
            }
        }
    }
}
}

This is one of the important differences from frameworks where effects are the only way to communicate. Here the root app is just Rust. If one handler needs to trigger behavior that another handler also uses, it can call the same capability or route to the same child app.

Keep the View Boring

The view model is data for the outside world:

#![allow(unused)]
fn main() {
pub enum ViewModel {
    LoggedOut {
        account: account::ViewModel,
    },
    LoggedIn {
        account: account::ViewModel,
        counter: counter::ViewModel,
    },
}
}

A UI can render it, map user input back into Event, and call update.