Skip to content

Full Async Actions

Now that we have introduced Events and Actions, we are going introduce a new mpsc::channel for Actions. The advantage of this is that we can programmatically trigger updates to the state of the app by sending Actions on the channel.

Here’s the run function refactored from before to introduce an Action channel. In addition to refactoring, we store the action_tx half of the channel in the App.

async fn run() -> Result<()> {
let (action_tx, mut action_rx) = mpsc::unbounded_channel(); // new
// ratatui terminal
let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
tui.enter()?;
// application state
let mut app = App {
counter: 0,
should_quit: false,
action_tx: action_tx.clone(),
};
loop {
let e = tui.next().await?;
match e {
tui::Event::Quit => action_tx.send(Action::Quit)?,
tui::Event::Tick => action_tx.send(Action::Tick)?,
tui::Event::Render => action_tx.send(Action::Render)?,
tui::Event::Key(_) => {
let action = get_action(&app, e);
action_tx.send(action.clone())?;
}
_ => {}
};
while let Ok(action) = action_rx.try_recv() {
// application update
update(&mut app, action.clone());
// render only when we receive Action::Render
if let Action::Render = action {
tui.draw(|f| {
ui(f, &mut app);
})?;
}
}
// application exit
if app.should_quit {
break;
}
}
tui.exit()?;
Ok(())
}

Running the code with this change should give the exact same behavior as before.

Now that we have stored the action_tx half of the channel in the App, we can use this to schedule tasks. For example, let’s say we wanted to press J and K to perform some network request and then increment the counter.

First, we have to update my Action enum:

#[derive(Clone)]
pub enum Action {
Tick,
Increment,
Decrement,
NetworkRequestAndThenIncrement, // new
NetworkRequestAndThenDecrement, // new
Quit,
Render,
None,
}

Next, we can update my event handler:

fn get_action(_app: &App, event: Event) -> Action {
match event {
Event::Error => Action::None,
Event::Tick => Action::Tick,
Event::Render => Action::Render,
Event::Key(key) => {
match key.code {
Char('j') => Action::Increment,
Char('k') => Action::Decrement,
Char('J') => Action::NetworkRequestAndThenIncrement, // new
Char('K') => Action::NetworkRequestAndThenDecrement, // new
Char('q') => Action::Quit,
_ => Action::None,
}
}
_ => Action::None,
}
}

Finally, we can handle the action in my update function by spawning a tokio task:

fn update(app: &mut App, action: Action) {
match action {
Action::Increment => {
app.counter += 1;
}
Action::Decrement => {
app.counter -= 1;
}
Action::NetworkRequestAndThenIncrement => {
let tx = app.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
tx.send(Action::Increment).unwrap();
});
}
Action::NetworkRequestAndThenDecrement => {
let tx = app.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
tx.send(Action::Decrement).unwrap();
});
}
Action::Quit => app.should_quit = true,
_ => {}
};
}

Here is the full code for reference:

mod tui;
use std::time::Duration;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode::Char;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::{self, UnboundedSender};
use tui::Event;
// App state
struct App {
counter: i64,
should_quit: bool,
action_tx: UnboundedSender<Action>,
}
// App actions
#[derive(Clone)]
pub enum Action {
Tick,
Increment,
Decrement,
NetworkRequestAndThenIncrement, // new
NetworkRequestAndThenDecrement, // new
Quit,
Render,
None,
}
// App ui render function
fn ui(f: &mut Frame, app: &mut App) {
let area = f.size();
f.render_widget(
Paragraph::new(format!(
"Press j or k to increment or decrement.\n\nCounter: {}",
app.counter,
))
.block(
Block::default()
.title("ratatui async counter app")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.style(Style::default().fg(Color::Cyan))
.alignment(Alignment::Center),
area,
);
}
fn get_action(_app: &App, event: Event) -> Action {
match event {
Event::Error => Action::None,
Event::Tick => Action::Tick,
Event::Render => Action::Render,
Event::Key(key) => {
match key.code {
Char('j') => Action::Increment,
Char('k') => Action::Decrement,
Char('J') => Action::NetworkRequestAndThenIncrement, // new
Char('K') => Action::NetworkRequestAndThenDecrement, // new
Char('q') => Action::Quit,
_ => Action::None,
}
}
_ => Action::None,
}
}
fn update(app: &mut App, action: Action) {
match action {
Action::Increment => {
app.counter += 1;
}
Action::Decrement => {
app.counter -= 1;
}
Action::NetworkRequestAndThenIncrement => {
let tx = app.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
tx.send(Action::Increment).unwrap();
});
}
Action::NetworkRequestAndThenDecrement => {
let tx = app.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
tx.send(Action::Decrement).unwrap();
});
}
Action::Quit => app.should_quit = true,
_ => {}
};
}
async fn run() -> Result<()> {
let (action_tx, mut action_rx) = mpsc::unbounded_channel(); // new
// ratatui terminal
let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
tui.enter()?;
// application state
let mut app = App {
counter: 0,
should_quit: false,
action_tx: action_tx.clone(),
};
loop {
let e = tui.next().await?;
match e {
tui::Event::Quit => action_tx.send(Action::Quit)?,
tui::Event::Tick => action_tx.send(Action::Tick)?,
tui::Event::Render => action_tx.send(Action::Render)?,
tui::Event::Key(_) => {
let action = get_action(&app, e);
action_tx.send(action.clone())?;
}
_ => {}
};
while let Ok(action) = action_rx.try_recv() {
// application update
update(&mut app, action.clone());
// render only when we receive Action::Render
if let Action::Render = action {
tui.draw(|f| {
ui(f, &mut app);
})?;
}
}
// application exit
if app.should_quit {
break;
}
}
tui.exit()?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let result = run().await;
result?;
Ok(())
}

With that, we have a fully async application that is tokio ready to spawn tasks to do work concurrently.