Build a Counter App in Rust egui — Buttons and State (Ep 3)
Video: Build a Counter App in Rust egui — Buttons and State (Ep 3) by Taught by Celeste AI - AI Coding Coach
Two
if ui.button(...).clicked()blocks. Onei32field. The whole interactivity model of egui.
In Episode 2 we displayed text. Today we make the UI react. Click a button, change a field, see the new value on the next frame. That cycle is the whole interactivity story in egui.
What we are building
A counter app. One number on screen, two buttons (Increment, Decrement) to change it, and a Reset button to set it back to zero. Three buttons, one i32, one format!.
We are also splitting the project across two files for the first time — main.rs for the runner, app.rs for the app logic. Once apps grow past a screen of code, this split keeps things readable.
The script
src/main.rs:
mod app;
use app::MyApp;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"Buttons and State",
options,
Box::new(|_cc| Ok(Box::new(MyApp::default()))),
)
}
src/app.rs:
use eframe::egui;
pub struct MyApp {
counter: i32,
}
impl Default for MyApp {
fn default() -> Self {
Self { counter: 0 }
}
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Buttons and State");
ui.separator();
ui.label(format!("Counter: {}", self.counter));
ui.horizontal(|ui| {
if ui.button("Increment").clicked() {
self.counter += 1;
}
if ui.button("Decrement").clicked() {
self.counter -= 1;
}
});
ui.separator();
if ui.button("Reset").clicked() {
self.counter = 0;
}
});
}
}
Three buttons, one counter, one display. Let us walk it.
ui.button and the Response
if ui.button("Increment").clicked() {
self.counter += 1;
}
ui.button(text) adds a button to the UI and returns a Response. Response carries everything you might want to know about the widget for this frame — was it hovered, clicked, dragged, focused, what is its rectangle on screen.
.clicked() is a method on Response that returns true if the user pressed and released the mouse over this widget during this frame. So the entire interaction pattern is:
- Add the widget. Get a
Response. - Check the
Responsefor the events you care about. - Mutate state if the relevant event occurred.
That is it. There is no callback registration, no event listener setup, no signal/slot wiring. The check happens inline, every frame, and only fires on the frame the click actually happens.
Response has many other methods: .hovered(), .has_focus(), .dragged(), .changed(), .double_clicked(), .context_menu(). The pattern is always the same — add widget, get response, check.
Mutable state
pub struct MyApp {
counter: i32,
}
The counter lives in MyApp. Because update takes &mut self, the body of update can read and write any field. self.counter += 1 mutates the struct in place. Next frame, format!("Counter: {}", self.counter) reads the new value, and the displayed number updates.
This is the immediate-mode advantage in concrete form. State is just fields on a struct. There is no framework-level state container, no derived value, no observable. If you can write &mut self, you can update the UI.
ui.horizontal
ui.horizontal(|ui| {
if ui.button("Increment").clicked() { ... }
if ui.button("Decrement").clicked() { ... }
});
By default, widgets stack vertically. ui.horizontal(|ui| { ... }) flips that for one block — widgets inside the closure flow left to right. The closure receives a new Ui reference; widgets added inside are nested inside the horizontal layout.
The two buttons sit side by side. After the closure ends, the next widget (the separator below) goes back to vertical flow.
ui.vertical exists too, but you rarely need it explicitly because vertical is the default. There is also ui.horizontal_wrapped for when you want widgets to wrap to a new line if the window is too narrow.
The frame model in action
Watch what happens when you click Increment:
- Frame N (no click yet):
updateruns.format!("Counter: {}", self.counter)produces "Counter: 0". The UI is drawn. - The user clicks the
Incrementbutton between frames. - Frame N+1:
updateruns again.ui.button("Increment")is added. The runtime knows a click happened on this widget..clicked()returnstrue.self.counter += 1runs.self.counteris now 1. The label below renders "Counter: 1". - Frame N+2 onwards: no click.
.clicked()returnsfalse. The body does not increment. The display stays at 1 until something else changes.
So .clicked() is "edge-triggered" — true only on the frame the click happens. You get the click once; you do not have to debounce or remember it.
Splitting into two files
mod app; in main.rs declares the app module. Rust looks for src/app.rs (or src/app/mod.rs) and includes it as a sibling module.
use app::MyApp; brings the type into scope.
pub struct MyApp — the pub is required so main.rs can see it from outside the module.
Multi-file projects scale better than one giant main.rs. A typical egui app might end up with app.rs for the main struct, widgets.rs for custom widgets, state.rs for data structures, io.rs for save/load. Same Cargo project; one binary.
Running it
cargo run. A window opens. Click Increment — the counter goes up. Click Decrement — it goes down. Click Reset — back to zero.
Hold a button down — nothing happens. clicked() only fires on a press-and-release. For continuous behaviour (hold-to-repeat), you would use is_pointer_button_down_on() or dragged().
What's next
Next episode: text input. ui.text_edit_singleline(&mut self.name) — a text box bound to a string field. Same pattern: state in a struct, mutate from the UI. Once you can read user-typed text, you can build forms.
Common mistakes
Forgetting to check .clicked(). ui.button("X"); adds the button but ignores all events. The button is visible but inert.
Mutating state outside update. A background thread that writes self.counter directly will race with the render. Use channels.
Doing expensive work inside update. Remember it runs every frame. Anything that takes more than a millisecond will cause stutter. Cache results in struct fields; trigger expensive work on clicked and store the output.
Trying to mutate self inside a show closure that already borrows self. Sometimes the borrow checker complains. The fix is to read into a local before the closure, mutate the local, and assign back after.
Recap
ui.button(text) returns a Response. .clicked() returns true for one frame after a click. Mutate self inside the if. The next frame's render reflects the new state. ui.horizontal(|ui| { ... }) for widgets side by side. Module split via mod app;.
Next episode: text input. See you in the next one.