Rust 88 🦀 Cross-Platform GUI Development (iced and egui)
Rust’s desktop GUI story has two dominant approaches, and the distinction is architectural. egui is an immediate-mode GUI library: on every frame, the application describes the entire UI from its state, and egui renders it and handles input in the same pass. iced is a retained-mode framework modeled on The Elm Architecture: the application defines a state, a message type, an update function that transforms state in response to messages, and a view function that describes the UI for the current state. The distinction determines how much structure the framework imposes and how the application manages state transitions.
egui is the simpler of the two. It has no retained widget tree, no message type, and no state container. The UI is a function of the application’s state, and the developer decides when to redraw and how to organize the code. This simplicity makes it the faster choice for tools, debug overlays, game editors, and anything where the UI is tightly coupled to a rendering loop or a simulation. The trade-off is that the application owns all the structure: the frame loop, the state management, and the integration with the platform’s event loop.
iced is the more structured framework. It enforces the Elm architecture: every interaction produces a message, every message flows through update, and the UI is a pure function of the state. This structure makes the application easier to test and reason about, and it scales to larger applications with multiple screens and complex state. The cost is more boilerplate: a message enum, an update function, a view function, and a subscription system for async events. The framework also provides iced::application, an entry point that manages the window and the event loop.
This chapter covers the immediate-mode model of egui, the Elm architecture of iced, the widget and layout systems of each, the async integration patterns, and the decision criteria that determine which library fits a given application. It assumes familiarity with Rust and basic GUI concepts.
Key point: egui is immediate-mode: the UI is a function of state, rendered every frame, with no retained widget tree. iced is retained-mode with the Elm architecture: state is centralized, messages drive updates, and the view is a pure function of state. Choose egui for tools and debug UIs where the loop is simple; choose iced for applications where the structure and testability of the Elm pattern are worth the boilerplate.
Why two GUI models coexist in Rust
Immediate mode eliminates the state synchronization problem. In a retained-mode framework, the widget tree and the application state can diverge: a text field holds a string, and the application holds a copy, and the two must be kept in sync. In an immediate-mode framework, the text field renders from the application’s string and writes changes back to it on the same frame. There is no separate widget state to synchronize . This is why egui is the natural choice for debug overlays and tools: the UI reads the simulation state directly, and there is no synchronization layer.
The Elm architecture makes state transitions explicit. In iced, every interaction produces a message, and the update function is the only place where state changes. This means the application’s behavior can be tested by sending messages and checking the resulting state, without rendering anything. The view function is a pure function of the state, so a given state always produces the same UI . This testability and predictability is why iced scales to larger applications.
Both integrate with the platform’s event loop differently. egui runs inside a eframe application, which owns the window and the event loop and calls the application’s update method on every frame. iced provides iced::application, which manages the window and dispatches messages to the update function. The egui loop is frame-driven: the UI is redrawn continuously or on demand. The iced loop is event-driven: the UI is redrawn when a message changes the state.
The trade-off. egui is faster to start and simpler for small tools, but the application must manage its own structure and the UI is rebuilt every frame. iced imposes more structure and more boilerplate, but that structure is what makes larger applications maintainable. The choice is between a library that gives you a render function and a framework that gives you an architecture.
a. egui: immediate-mode UI inside eframe
The eframe crate is the official egui integration for native platforms and the web. It provides the window, the event loop, and the rendering backend (wgpu, glow, or WebGPU). The application implements the eframe::App trait, which has an update method that is called on every frame .
use eframe::egui;
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Counter");
ui.horizontal(|ui| {
if ui.button("−").clicked() {
self.count -= 1;
}
ui.label(format!("{}", self.count));
if ui.button("+").clicked() {
self.count += 1;
}
});
});
}
}
fn main() -> eframe::Result<()> {
eframe::run_native(
"Counter",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(CounterApp { count: 0 }))),
)
}
The update method receives the egui::Context, which is the handle for building UI. The CentralPanel is a container that fills the available area; SidePanel, TopBottomPanel, and Window are the other containers. The ui object is the builder for widgets: button, label, heading, text_edit_singleline, checkbox, slider, and others . Every widget returns a response object with clicked(), changed(), and hovered() methods, which the application uses to update its state.
The immediate-mode model is visible in the code: the button does not store a click handler. It returns a response, and the if statement checks whether the button was clicked on this frame. The state update happens in the same update call that rendered the button . This is why there is no message type and no update function separate from the render function: they are the same function.
For applications that need continuous redraws (animations, live data), the update method can call ctx.request_repaint() to schedule the next frame. Without it, egui redraws only in response to input, which is the efficient default for static UIs .
b. iced: the Elm architecture with messages and subscriptions
An iced application has four parts: the state, the message enum, the update function, and the view function. The iced::application entry point ties them together. The state is a plain struct. The message enum lists every interaction the application can handle. The update function transforms the state in response to a message. The view function describes the UI for the current state .
use iced::widget::{button, column, text};
use iced::{Element, Task};
#[derive(Debug, Clone)]
enum Message {
Increment,
Decrement,
}
#[derive(Default)]
struct Counter {
count: i32,
}
impl Counter {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Increment => self.count += 1,
Message::Decrement => self.count -= 1,
}
Task::none()
}
fn view(&self) -> Element<Message> {
column![
button("−").on_press(Message::Decrement),
text(self.count).size(32),
button("+").on_press(Message::Increment),
]
.into()
}
}
fn main() -> iced::Result {
iced::application(
Counter::default,
Counter::update,
Counter::view,
)
.run()
}
The view function returns an Element<Message>, which is a description of the UI. The column! macro and the widget functions (button, text, text_input, checkbox, slider, row!, container) build this description. The .on_press(Message::Increment) method attaches a message to the button; when the button is clicked, the message is sent to the update function .
The Task<Message> return from update is how iced handles async work. A task represents a side effect—an HTTP request, a file read, a timer—that produces a message when it completes. The Task::perform function wraps an async future into a task, and the result is delivered as a message .
use iced::Task;
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::LoadData => {
return Task::perform(
async { fetch_data().await },
Message::DataLoaded,
);
}
Message::DataLoaded(data) => {
self.data = data;
}
}
Task::none()
}
Subscriptions are the other async mechanism. A subscription listens for external events—keyboard input, window resize, a timer tick—and produces messages. The iced::time::every function creates a subscription that fires on an interval, which is how an iced application implements a clock or a polling loop .
Complete Example Session
// ============================================
// PART 1: EGUI — STATE AND APP
// ============================================
use eframe::egui;
struct App {
name: String,
age: u8,
subscribed: bool,
}
impl Default for App {
fn default() -> Self {
Self { name: String::new(), age: 0, subscribed: false }
}
}
// ============================================
// PART 2: EGUI — UPDATE METHOD
// ============================================
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Profile");
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut self.name);
});
ui.add(egui::Slider::new(&mut self.age, 0..=120).text("Age"));
ui.checkbox(&mut self.subscribed, "Subscribe");
if ui.button("Save").clicked() {
println!("Saved: {} ({})", self.name, self.age);
}
});
}
}
// ============================================
// PART 3: EGUI — MAIN
// ============================================
fn main() -> eframe::Result<()> {
eframe::run_native(
"Profile",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(App::default()))),
)
}
// ============================================
// PART 4: EGUI — LAYOUT CONTAINERS
// ============================================
// egui::CentralPanel::default().show(ctx, |ui| { ... })
// egui::SidePanel::left("sidebar").show(ctx, |ui| { ... })
// egui::TopBottomPanel::top("header").show(ctx, |ui| { ... })
// egui::Window::new("Floating").show(ctx, |ui| { ... })
// ui.horizontal(|ui| { ... })
// ui.vertical(|ui| { ... })
// ============================================
// PART 5: EGUI — REQUEST REPAINT
// ============================================
// For animations or live data:
// ctx.request_repaint();
// ============================================
// PART 6: ICED — STATE AND MESSAGE
// ============================================
use iced::widget::{button, column, row, text, text_input};
use iced::{Element, Task};
#[derive(Debug, Clone)]
enum Msg {
NameChanged(String),
AgeChanged(u8),
Save,
}
struct Profile {
name: String,
age: u8,
}
impl Default for Profile {
fn default() -> Self {
Self { name: String::new(), age: 0 }
}
}
// ============================================
// PART 7: ICED — UPDATE AND VIEW
// ============================================
impl Profile {
fn update(&mut self, message: Msg) -> Task<Msg> {
match message {
Msg::NameChanged(n) => self.name = n,
Msg::AgeChanged(a) => self.age = a,
Msg::Save => println!("Saved: {} ({})", self.name, self.age),
}
Task::none()
}
fn view(&self) -> Element<Msg> {
column![
text("Profile"),
text_input("Name", &self.name).on_input(Msg::NameChanged),
text(format!("Age: {}", self.age)),
button("Save").on_press(Msg::Save),
]
.padding(20)
.into()
}
}
// ============================================
// PART 8: ICED — MAIN
// ============================================
// fn main() -> iced::Result {
// iced::application(
// Profile::default,
// Profile::update,
// Profile::view,
// )
// .run()
// }
// ============================================
// PART 9: ICED — ASYNC TASK
// ============================================
// Message::Load => {
// return Task::perform(
// async { fetch().await },
// Message::Loaded,
// );
// }
// ============================================
// PART 10: THE DECISION
// ============================================
// egui: immediate mode, render every frame.
// Use for tools, debug UIs, game editors.
// Simpler state management.
// eframe owns the window.
//
// iced: Elm architecture, messages + update.
// Use for applications with complex state.
// Testable state transitions.
// Task and subscription for async.
Quick Reference
Architecture Comparison
| Aspect | egui | iced |
|---|---|---|
| Model | Immediate mode | Elm architecture |
| UI | Function of state, every frame | view returns Element<Message> |
| State | Application struct | Application struct |
| Events | Response objects (clicked()) | Messages + update function |
| Async | Manual via request_repaint | Task::perform, subscriptions |
| Window | eframe | iced::application |
| Boilerplate | Low | Medium |
egui Widgets
| Widget | Method |
|---|---|
| Button | ui.button("Label").clicked() |
| Text input | ui.text_edit_singleline(&mut string) |
| Slider | ui.add(Slider::new(&mut val, range)) |
| Checkbox | ui.checkbox(&mut bool, "Label") |
| Combo box | ComboBox::from_label("...").show_ui(...) |
iced Widgets
| Widget | Method |
|---|---|
| Button | button("Label").on_press(Msg) |
| Text input | text_input("Placeholder", &value).on_input(Msg) |
| Slider | slider(range, value, Msg) |
| Checkbox | checkbox("Label", value).on_toggle(Msg) |
| Text | text("Content") |
Async Patterns
| Library | Mechanism |
|---|---|
egui | ctx.request_repaint() + background thread |
iced | Task::perform(future, Msg::Variant) |
iced | Subscription for timers/events |
Best Practices
✅ Do This:
// egui: render UI from state each frame. // ✅
ui.label(format!("{}", self.count));
// egui: check response immediately. // ✅
if ui.button("Save").clicked() { self.save(); }
// iced: route all state changes through update. // ✅
Msg::Increment => self.count += 1,
// iced: use Task for async side effects. // ✅
Task::perform(async { ... }, Msg::Done)
// iced: keep view a pure function of state. // ✅
fn view(&self) -> Element<Msg> { ... }
❌ Don’t Do This:
// egui: block the UI thread with long I/O. // ❌
// Use a background thread + request_repaint.
// egui: expect widget state persistence. // ❌
// Immediate mode rebuilds every frame.
// iced: mutate state outside update. // ❌
// Breaks the Elm architecture guarantees.
// iced: perform side effects in view. // ❌
// view should be pure; use Task.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
egui UI does not update | No repaint requested | ctx.request_repaint() |
egui blocks on I/O | Sync call in update | Background thread + channel |
iced view has side effects | Calling functions in view | Move to update via Task |
iced message not handled | Missing match arm | Exhaustive match |
| Window does not open | Missing run_native/application | Check entry point |
Real-World Examples
1. egui Counter
if ui.button("+").clicked() { self.count += 1; }
ui.label(format!("{}", self.count));
2. egui Text Input
ui.text_edit_singleline(&mut self.name);
3. egui Slider
ui.add(egui::Slider::new(&mut self.age, 0..=120));
4. egui Side Panel
egui::SidePanel::left("nav").show(ctx, |ui| { ... });
5. iced Button
button("+").on_press(Message::Increment)
6. iced Text Input
text_input("Name", &self.name).on_input(Message::NameChanged)
7. iced Column Layout
column![text("A"), text("B")].spacing(10)
8. iced Async Task
Task::perform(async { fetch().await }, Message::Loaded)
9. egui Repaint
ctx.request_repaint();
10. iced Application Entry
iced::application(App::default, App::update, App::view).run()
Visual
egui Immediate Mode
┌──────────────────────────────────────────────┐
│ FRAME N │
│ │
│ update(ctx) called │
│ │ │
│ ▼ │
│ Build UI from state │
│ button("+") → response │
│ │ │
│ ▼ │
│ if response.clicked() { state += 1 } │
│ │ │
│ ▼ │
│ Render frame │
│ │
│ FRAME N+1: same cycle. │
│ No widget tree. No messages. │
│ │
└──────────────────────────────────────────────┘
iced Elm Architecture
┌──────────────────────────────────────────────┐
│ USER INTERACTION │
│ │ │
│ ▼ │
│ Message::Increment │
│ │ │
│ ▼ │
│ update(&mut state, Message) → Task │
│ │ │
│ ▼ │
│ State changed │
│ │ │
│ ▼ │
│ view(&state) → Element<Message> │
│ │ │
│ ▼ │
│ Framework renders │
│ │
│ State changes only in update. │
│ view is pure. │
│ │
└──────────────────────────────────────────────┘
Async Integration
┌──────────────────────────────────────────────┐
│ EGUI ICED │
│ │
│ Background thread Task::perform │
│ │ │ │
│ ▼ ▼ │
│ Send result via Future resolves │
│ channel │ │
│ │ ▼ │
│ ▼ Message delivered │
│ Update state to update │
│ ctx.request_repaint() │
│ │
│ Manual coordination. Framework-managed. │
│ │
└──────────────────────────────────────────────┘
Decision Flow
┌──────────────────────────────────────────────┐
│ WHICH LIBRARY? │
│ │
│ Tool, debug UI, game editor? │
│ └── egui │
│ │
│ Complex app state, multiple screens? │
│ └── iced │
│ │
│ Need testable state transitions? │
│ └── iced (Elm architecture) │
│ │
│ UI tightly coupled to render loop? │
│ └── egui │
│ │
│ Minimal boilerplate, fast start? │
│ └── egui │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
egui model | Immediate mode, render every frame |
iced model | Elm architecture, messages + update |
egui window | eframe::run_native |
iced window | iced::application(...).run() |
egui events | Response objects (clicked()) |
iced events | Messages via .on_press(Msg) |
egui async | Background thread + request_repaint |
iced async | Task::perform, subscriptions |
egui state | Application struct, no message type |
iced state | Application struct + message enum |
Key takeaways:
eguiis immediate-mode: the UI is a function of state, rendered every frame. Widgets return response objects, and the application checksclicked()orchanged()in the same function that rendered the widget. There is no message type, no separateupdatefunction, and no widget tree .icedis retained-mode with the Elm architecture. State changes only in theupdatefunction, which receives a message. Theviewfunction is a pure function of state that returns anElement<Message>. This structure makes state transitions testable and predictable .- Both libraries integrate with async work, but differently.
eguirequires a background thread andctx.request_repaint().icedprovidesTask::performand subscriptions, which deliver async results as messages to theupdatefunction . eguiis faster to start;icedscales better. The immediate-mode model eliminates state synchronization, which is why it is the natural choice for tools and debug UIs. The Elm architecture makes larger applications maintainable, which is why it is the choice for applications with complex state .- The entry points are
eframe::run_nativeandiced::application. Both manage the window and the event loop. The application code is the difference:eframecallsupdateon every frame;iceddispatches messages toupdateand rendersviewwhen the state changes.
Remember: The choice between egui and iced is a choice about structure. egui gives you a render function and a context, and you build the loop and the state management yourself. iced gives you an architecture: messages, update, view, and tasks. Both are cross-platform and both are actively maintained. egui is the faster path to a working tool; iced is the more maintainable path to a working application. The UI code reflects the difference: egui checks a button’s response immediately after rendering it, while iced attaches a message and handles it in a separate function. Neither model is superior; they solve different problems.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!