Async Data

Load data asynchronously with automatic loading and error state management.

let user = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(2));
    fetch_user_from_api()
});

match &user {
    Async::Loading => View::text("Loading..."),
    Async::Ready(data) => View::text(format!("Welcome, {}!", data.name)),
    Async::Error(e) => View::styled_text(format!("Error: {}", e)).color(Color::Red).build(),
}

Run with: cargo run -p telex-tui --example 24_async_data

What is async_data!?

async_data! runs a closure in a background thread and returns an Async<T> enum representing the current state:

pub enum Async<T> {
    Loading,           // Task is running
    Ready(T),          // Task succeeded
    Error(String),     // Task failed
}

This handles the common pattern of showing loading spinners, displaying data when ready, and showing errors when things fail.

Basic usage

let data = async_data!(cx, || {
    // This runs in a background thread
    std::thread::sleep(Duration::from_secs(1));

    // Return Result<T, String>
    Ok("Data loaded successfully".to_string())
});

// Pattern match on the state
match &data {
    Async::Loading => View::text("Loading..."),
    Async::Ready(value) => View::text(value),
    Async::Error(err) => View::text(format!("Error: {}", err)),
}

The closure must return Result<T, String> where T is your data type.

Lifecycle

  1. Start: When the component first renders, the async task starts immediately
  2. Loading: Async::Loading until the task completes
  3. Complete: Once finished, becomes either Async::Ready(data) or Async::Error(msg)
  4. Cleanup: If the component unmounts while loading, the task is cancelled

Helper methods

Instead of pattern matching, you can use helper methods:

let data = async_data!(cx, || fetch_data());

// Check state
if data.is_loading() {
    // Show spinner
}

if data.is_error() {
    // Show error banner
}

// Get value if ready
if let Some(value) = data.as_ref().ok() {
    // Use value
}

Multiple async operations

Load multiple things in parallel:

let profile = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(2));
    Ok(fetch_user_profile())
});

let stats = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(1));
    Ok(fetch_user_stats())
});

let posts = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(3));
    Ok(fetch_user_posts())
});

// Each loads independently
// They don't wait for each other

Each async_data! runs in its own thread. They start simultaneously and complete at different times.

Error handling

Return Err to indicate failure:

let data = async_data!(cx, || {
    match fetch_from_api() {
        Ok(data) => Ok(data),
        Err(e) => Err(format!("API error: {}", e)),
    }
});

match &data {
    Async::Error(msg) => View::styled_text(msg).color(Color::Red).build(),
    _ => // ... handle loading and ready states
}

The error message is stored as a String.

Loading UI patterns

Simple spinner:

match &data {
    Async::Loading => View::text("Loading..."),
    Async::Ready(d) => render_data(d),
    Async::Error(e) => render_error(e),
}

Progress bar:

match &profile {
    Async::Loading => View::vstack()
        .child(View::text("Loading profile..."))
        .child(View::text("[==========>          ]"))
        .build(),
    Async::Ready(p) => render_profile(p),
    Async::Error(e) => render_error(e),
}

Overall status:

let all_loaded = !profile.is_loading() && !stats.is_loading() && !posts.is_loading();
let any_error = profile.is_error() || stats.is_error() || posts.is_error();

let status = if any_error {
    "Some requests failed"
} else if all_loaded {
    "All data loaded"
} else {
    "Loading..."
};

View::text(format!("Status: {}", status))

Fetching from APIs

Real-world HTTP example:

let user_data = async_data!(cx, || {
    // Using reqwest or similar
    match reqwest::blocking::get("https://api.example.com/user/123") {
        Ok(response) => match response.json::<User>() {
            Ok(user) => Ok(user),
            Err(e) => Err(format!("Parse error: {}", e)),
        },
        Err(e) => Err(format!("Network error: {}", e)),
    }
});

Database queries

Loading from a database:

let records = async_data!(cx, || {
    match db::fetch_all_records() {
        Ok(data) => Ok(data),
        Err(e) => Err(format!("Database error: {}", e)),
    }
});

match &records {
    Async::Loading => View::text("Querying database..."),
    Async::Ready(rows) => View::list().items(rows.clone()).build(),
    Async::Error(e) => View::text(format!("Query failed: {}", e)),
}

File I/O

Loading configuration files:

let config = async_data!(cx, || {
    match std::fs::read_to_string("config.json") {
        Ok(contents) => match serde_json::from_str(&contents) {
            Ok(cfg) => Ok(cfg),
            Err(e) => Err(format!("Invalid JSON: {}", e)),
        },
        Err(e) => Err(format!("File error: {}", e)),
    }
});

Dependent loading

If one load depends on another, use effect! to trigger the second:

let user_id = state!(cx, || None);
let user_profile = state!(cx, || Async::Loading);

// Load user list
let users = async_data!(cx, || Ok(fetch_users()));

// When a user is selected, load their profile
effect!(cx, user_id.get(), with!(user_profile => move |id| {
    if let Some(id) = id {
        // Trigger profile load
        let profile = load_profile(*id);
        user_profile.set(Async::Ready(profile));
    }
    || {}
}));

This is more complex. For simpler cases, load everything upfront.

Refreshing data

To reload, you need to trigger a re-creation of the async task:

let refresh_key = state!(cx, || 0);

let data = async_data!(cx, {
    let key = refresh_key.get();
    move || {
        let _ = key;  // capture key to make closure depend on it
        fetch_data()
    }
});

// Button to refresh
View::button()
    .label("Refresh")
    .on_press(with!(refresh_key => move || {
        refresh_key.update(|k| *k += 1);  // triggers new async task
    }))
    .build()

Changing the closure's captured values causes async_data! to restart.

Note: This pattern is a workaround - the dummy refresh_key is captured just to force the async task to re-run. A more ergonomic API for refreshing async data is planned for a future version.

Async vs Streams

Use async_data! when:

  • One-time data fetch (API call, database query, file load)
  • Clear start and end (loading → ready/error)
  • User-triggered or component mount triggered

Use stream! when:

  • Continuous data updates (polling, tailing, monitoring)
  • No clear "done" state
  • Data keeps flowing over time

See Streams for continuous data.

Performance tips

Parallel is automatic - Multiple async_data! calls run in parallel without extra work.

Blocking is fine - Async tasks run in background threads, so blocking operations (network, disk, sleep) don't freeze the UI.

Cancellation is automatic - If the component unmounts, background threads are cleaned up.

Don't overuse - Each async_data! is a thread. Dozens is fine, hundreds is excessive.

Common patterns

API with timeout:

let data = async_data!(cx, || {
    let handle = std::thread::spawn(|| fetch_from_api());

    match handle.join_timeout(Duration::from_secs(10)) {
        Ok(Ok(data)) => Ok(data),
        Ok(Err(e)) => Err(format!("Request failed: {}", e)),
        Err(_) => Err("Request timed out".to_string()),
    }
});

Retry logic:

let data = async_data!(cx, || {
    for attempt in 1..=3 {
        match fetch_data() {
            Ok(data) => return Ok(data),
            Err(e) if attempt == 3 => return Err(format!("Failed after 3 attempts: {}", e)),
            Err(_) => std::thread::sleep(Duration::from_secs(1)),
        }
    }
    unreachable!()
});

Graceful degradation:

match &data {
    Async::Loading => render_skeleton(),  // placeholder UI
    Async::Ready(d) => render_full_ui(d),
    Async::Error(_) => render_cached_data(),  // fallback to stale data
}

Tips

Return Ok/Err explicitly - The closure must return Result<T, String>.

Errors are strings - Convert your error types to strings: Err(format!("...", e)).

Loading is the initial state - Even before the thread starts, the state is Async::Loading.

No retry mechanism - If the task fails, it stays failed. Implement retry logic inside the closure.

Pattern match or use helpers - Either match &async_data or .is_loading() / .is_error().

Clone for rendering - Async<T> derefs to &T when Ready, but pattern matching gives you references. Clone if needed for rendering.

Async data is not reactive - Unlike state!, modifying the value inside Async::Ready doesn't trigger re-renders. Async tasks run once.

Next: Tables