🔮 egui-async
A simple, batteries-included library for running async tasks across frames in egui and binding their results to your UI.
Supports both native (Tokio) and wasm32 (Web) targets out of the box.
📖 Overview
Immediate-mode GUI libraries like egui are fantastic, but they pose a significant challenge: how do you run long-running async tasks (like HTTP requests or file I/O) without freezing the UI thread?
egui-async solves this by providing a Bind<T, E> struct. This struct acts as a state machine that bridges the gap between your immediate-mode render loop and your background async runtime.
It handles the lifecycle of the Future, manages the state transitions (Idle → Pending → Finished), and provides ergonomic UI widgets to visualize that state.
✨ Features
- 🔄 Smart State Management: Automatically tracks
Idle,Pending, andFinishedstates. No more manualOption<Result<...>>juggling. - 🌐 Universal Support: Seamlessly switches between
tokio(native) andwasm-bindgen-futures(web). Write your code once, run everywhere. - ⚡ Lazy Loading:
read_or_requestallows you to ergonomically trigger async fetches just by trying to read the data in your UI code. - ⏱️ Periodic Updates: Built-in support for polling data at specific intervals (e.g., every 10 seconds).
- 🛑 Task Abortion: Supports physically aborting background tasks on native targets when the UI state changes.
- 🧩 Enterprise Widgets: Drop-in UI components like
AsyncButton,AsyncSearch, andAsyncViewthat handle spinners, debouncing, and layout snapping automatically. - 🛠️ Batteries Included: Includes the async runtime for you, along with helper extension traits for popups and retry logic.
📦 Installation
🧩 Compatibility
egui APIs change frequently. Ensure you are using a compatible version of egui-async for your project.
egui-async |
egui |
|---|---|
>=0.2.0 |
0.33 |
<=0.1.1 |
0.32 |
🚀 Quick Start
Using egui-async requires two steps: registering the plugin and using a Bind.
1. Register the Plugin
You must register the EguiAsyncPlugin in your update loop. This drives the frame timers and ensures background tasks can request UI repaints.
2. Bind Data
Use Bind<T, E> to manage your data.
use Bind;
// Inside your update loop:
if let Some = self.my_ip.read_or_request else
💡 Usage Patterns
egui-async is designed to fit several different UI patterns depending on how much control you need.
Pattern 1: Lazy Loading ("The Getter")
Scenario: You have data that should load automatically when the user opens a specific tab or window.
Solution: Use read_or_request. If the data isn't there, it triggers the request and returns None (so you can show a spinner). If it is there, it returns the data.
// If data is missing, start fetching it.
// If fetching, show a spinner.
// If finished, show the result.
if let Some = self.data.read_or_request else
Pattern 2: Explicit State Control ("Full State Machine")
Scenario: You need a complex UI that looks completely different depending on whether it is loading, failed, or successful (e.g., a login screen).
Solution: Use state_or_request to match exhaustively on every possible state.
use StateWithData;
match self.login.state
Pattern 3: The Live Feed ("Periodic Refresh")
Scenario: You are building a dashboard and need to fetch status updates every 5 seconds.
Solution: Use request_every_sec. It respects the timer and only triggers a new request when the interval has elapsed since the last completion.
// Automatically re-run the future if 5.0 seconds have passed since the last finish.
self.server_status.request_every_sec;
// Display the current (cached) data
if let Some = self.server_status.read
Pattern 4: The Power User ("Ui Extensions")
Scenario: You want a standard "Refresh" button that handles debouncing, loading spinners, and tooltips automatically.
Solution: Use the UiExt trait methods like refresh_button or popup_error.
use UiExt; // Import the trait
// Renders a button that spins while Pending.
// Clicking it forces a refresh.
// It also auto-refreshes every 60 seconds.
//
// We use egui's underlying monotonic clock for timing,
// so we aren't making IO time calls every frame! 😉
ui.refresh_button;
// If the bind failed, show a popup window with the error and a "Retry" button.
self.data.read_or_error;
Pattern 5: Enterprise Widgets
Scenario: You want battle-tested, drop-in UI components that handle loading states, layout snapping, and debouncing automatically without writing boilerplate.
Solution: Use the included egui_async::egui widgets like AsyncButton or AsyncView.
use ;
// A button that prevents double-clicks and shows an inline spinner
new
.pending_text
.show;
// A declarative container that completely manages the Idle/Pending/Ok/Err lifecycle
new
.state_layout
.show;
🛠️ Building Custom Widgets
Building your own async-aware widgets is straightforward. Pass a &mut Bind<T, E> to your widget, and use its state methods (is_pending(), read(), etc.) to drive your rendering logic, calling bind.request() or bind.refresh() when the user interacts.
🧑💻 See it in action:
You can find complete, runnable examples for all these patterns in the examples/ directory of the repository:
simple.rs– A minimal HTTP fetch example.login.rs– The full "State Machine" pattern with forms and validation.periodic.rs– A dashboard widget that auto-refreshes.advanced.rs- An online, IP locator tool, with maps.widgets.rs- A showcase of all included async widgets (AsyncButton,AsyncSearch,AsyncView).
Look at the code before you run it and try to predict what it does and what it will look like!
⚙️ Configuration
Retain Policy
By default (Bind::default()), egui-async assumes immediate-mode behavior: if you stop calling poll() (or read_*) on a Bind for a frame, it assumes the UI element is no longer visible and drops the data to save memory and reset the state.
If you want to keep data and state even when the UI component is hidden (e.g., inside a collapsed header or a closed tab), set retain to true:
let mut bind = new; // Retain = true
Native Task Abort
On native targets (non-WASM), you can configure Bind to physically abort the Tokio task when the request is cleared or overwritten. This is useful for cancelling heavy computations or large downloads.
let mut bind = new;
bind.set_abort; // Enable physical cancellation (Native only)
⚠️ WebAssembly Note:
Due to browser limitations,
set_aborthas no effect on WASM targets. TheFuturewill run to completion, but its result will be ignored by theBind.There are some methods to do this inside the browser if you desire, but you will need to manually implement it.
🔧 Under the Hood
How does egui-async bridge the gap between Immediate Mode GUI (60fps loop) and Asynchronous Runtimes?
- The Plugin: The
EguiAsyncPluginacts as the heartbeat. It synchronizes a global atomic clock withegui's input time. This allowsBindinstances to measure durations (like "time since finished") without expensive syscalls or mutex locking on every frame. - The Channel: When you call
request(), we spawn a task on the runtime (Tokio or Wasm). We give that task aoneshot::Sender. TheBindstruct holds theoneshot::Receiver. - Non-Blocking Polling: On every frame where the UI element is drawn,
Bind::poll()checks the receiver.
- If the channel is empty, it returns
Pending(andeguicontinues drawing). - If the channel has data, it moves the state to
Finished. - Crucially, if the data arrives between frames,
Bindautomatically requests a repaint from theContext, ensuring your UI updates immediately without user interaction.
Spawning
On Native targets, tasks are spawned onto the tokio runtime (specifically using tokio::spawn).
On WASM targets, tasks are spawned onto the browser's event loop using wasm_bindgen_futures::spawn_local. This detection happens automatically at compile time.
⚠️ Common Issues
1. Forgetting the Plugin
If your UI is stuck in Pending forever or your periodic requests aren't triggering, check your update loop.
2. Dropping the Bind
The Bind struct owns the receiving end of the async channel. If you create a Bind inside a function scope (local variable) instead of your App struct, it will be dropped at the end of the function, cancelling the specific UI binding.
- Bad:
let mut my_bind = Bind::new(false);insideupdate(). - Good:
self.my_bindinsidestruct MyApp.
3. The "Disappearing Data" Mystery
By default, Bind uses retain = false. This means if read() or poll() is not called during a specific frame (e.g., the user switched to a different tab in your app), egui-async assumes the data is no longer needed and clears it to free memory.
- Fix: If you want data to persist while hidden, use
Bind::new(true).
🌍 WASM Configuration
Some crates have features that must be enabled, or must be disabled, under WASM or WASM running in a browser runtime. If you're seeing nonsensical errors in your browser console, consider removing dependencies until things work, then slowly adding them back to see what breaks the runtime.
A very common example of this issue is the getrandom crate. Be sure to read relevant documentation for how to handle the browser runtime. See the getrandom wasm32 documentation.
🤝 Contributing
Contributions are more than welcome! If you find a bug or have a feature request, please open an issue. If you want to contribute code, please submit a pull request.
There are many opportunities to create async-native structs on the UiExt trait inside of src/egui.rs that would make for great first-contributions!
Special thank you to our contributors:
- @sectore
⚖️ License
This project is licensed under either of
- Apache License, Version 2.0, (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Note
This is not an official egui product. Please refer to https://github.com/emilk/egui for official crates and recommendations.