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
Eventenum for inputs; - private model state;
- a
ViewModelfor 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.