egui Persistence: Save & Restore Settings with serde | Rust GUI Ep 23
Video: egui Persistence: Save & Restore Settings with serde | Rust GUI Ep 23 by Taught by Celeste AI - AI Coding Coach
eframe::set_value(storage, eframe::APP_KEY, self)plus#[derive(Serialize, Deserialize)]. Five lines of code, full app-state persistence.
We have built a dozen apps so far and every one resets to default every time you run it. Today we fix that for good. eframe ships with a built-in storage mechanism — a small key-value store backed by a file on disk — and the serde crate handles serialisation. Wire them together and your app's state survives restarts.
What we are building
A settings panel: username, dark mode toggle, font size slider, notifications checkbox, volume slider. Type, drag, click — close the app, reopen it. Everything is exactly where you left it. The whole "save and load" mechanism is three method overrides plus the right derives.
What's in the state
#[derive(Serialize, Deserialize)]
pub struct MyApp {
username: String,
dark_mode: bool,
font_size: f32,
notifications: bool,
volume: f32,
}
The #[derive(Serialize, Deserialize)] is the key piece. It tells serde how to convert this struct to and from a string format (eframe uses RON by default). Every field type must also implement Serialize/Deserialize. Standard types (String, bool, f32, Vec<T>, HashMap<K, V>) all implement them already; for custom types, derive on those too.
You'll also need to add serde = { version = "1", features = ["derive"] } to Cargo.toml.
Loading saved state
impl MyApp {
pub fn new(cc: &eframe::CreationContext) -> Self {
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY)
.unwrap_or_default();
}
Self::default()
}
}
A constructor instead of Default::default(). eframe passes a CreationContext to the closure you provide to run_native. From it you can access the storage object — the key-value store eframe uses to persist data.
eframe::get_value(storage, key) reads the saved value at the given key, deserialised into your type. APP_KEY is the standard key eframe expects for the main app state. If nothing's saved (first launch), get_value returns None, and .unwrap_or_default() falls back to the default state.
In main.rs:
eframe::run_native(
"Settings App",
options,
Box::new(|cc| Ok(Box::new(MyApp::new(cc)))),
)
Pass the CreationContext to the constructor. That is how MyApp::new gets access to cc.storage.
Saving state
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
A method on the App trait. eframe calls it periodically — once every 30 seconds by default, plus when the app shuts down. Override it, write the entire self to the standard APP_KEY, and your state is on disk.
eframe::set_value serialises with serde and writes to the storage. The format is RON (Rusty Object Notation) — text, human-readable, but you don't usually open it. The file lives in a platform-specific config directory: ~/.local/share/your-app-name/ on Linux, similar paths on macOS and Windows.
Where the file lives
eframe picks the path automatically based on your binary name. On macOS:
~/Library/Application Support/<binary-name>/app.ron
On Linux:
~/.local/share/<binary-name>/app.ron
On Windows:
%APPDATA%\<binary-name>\app.ron
If you need a custom path or want to disable persistence, eframe has options. For most apps, the default location is fine.
Migrating across versions
What happens when you change the struct? Two scenarios:
Add a field. Old saved state is missing the new field. serde returns an error → .unwrap_or_default() falls back. The user loses all state because of one missing field.
Better: mark each new field with #[serde(default)]:
#[derive(Serialize, Deserialize)]
pub struct MyApp {
username: String,
dark_mode: bool,
font_size: f32,
notifications: bool,
volume: f32,
#[serde(default)]
new_field: String, // gets `Default::default()` when missing
}
Now adding a field doesn't break old saves; it just gets the default for that field.
Remove or rename a field. The old field still appears in the saved file. serde ignores unknown fields by default, so this is safe — but the data is gone.
For renames, use #[serde(rename = "old_name")] to keep reading the old key.
For genuine schema migrations (changing types, restructuring), version the data:
#[derive(Serialize, Deserialize, Default)]
pub struct MyApp {
#[serde(default = "schema_version_2")]
schema_version: u32,
// ...
}
fn schema_version_2() -> u32 { 2 }
Then in MyApp::new, check the version and run a migration if needed.
What you should and shouldn't persist
Persist:
- User preferences (theme, font size, layout).
- Window position and size (eframe handles this for you with persist_window).
- Recent files, last-used directories.
- Form drafts if it's a stateful form.
Don't persist: - Authentication tokens (use system keychains). - Large data sets (use a real database, e.g. SQLite). - Anything sensitive without encryption. - Cache or computed state — serialising it just slows down save and load.
eframe's persistence is for small, stable, user-visible state. For anything else, pick a different mechanism.
Running it
cargo run. Set the username, toggle dark mode, change font size, adjust volume. Wait a few seconds (or close the window). Run cargo run again. Everything is back where you left it.
Try editing the saved file directly with a text editor — you can see the RON format, modify a value, save, restart, and watch the change take effect. Useful for debugging.
Reset to defaults
if ui.button("Reset to Defaults").clicked() {
*self = Self::default();
}
Replacing *self with a fresh Default::default() resets the whole struct. The next save (within ~30s) writes the default state to disk; the user can rely on Reset to actually clear persisted state.
If you want immediate persistence after a reset, eframe doesn't have a direct API to force-save, but the next periodic save catches up quickly.
Common mistakes
Forgetting to call MyApp::new(cc) from run_native. If your runner uses Box::new(|_cc| Ok(Box::new(MyApp::default()))), the CreationContext is never threaded through and persistence doesn't kick in.
Not deriving Default on the struct. unwrap_or_default() requires Default. Either derive it or implement manually.
Storing non-Serialize types. Custom structs need their own derives. Or wrap them in Option<T> with #[serde(skip)] to leave them out of persistence.
Saving every frame. Don't override save more aggressively than eframe's default cadence. Disk writes are slow; the every-30-seconds default is the right balance.
Persisting passwords or tokens. RON files are plaintext. Anyone with the user's home directory can read them. Use a secrets manager.
What's next
Next episode: custom widgets. Build a reusable impl egui::Widget so your toolbar buttons or labelled inputs become a single line wherever you need them. The pattern that turns repeated UI into clean, named components.
Recap
#[derive(Serialize, Deserialize)] on the app struct. Implement MyApp::new(cc) to load from cc.storage via eframe::get_value(storage, eframe::APP_KEY). Override save(&mut self, storage) to write back via eframe::set_value. Pass cc from run_native's closure into your constructor. Use #[serde(default)] for forward-compatibility when adding fields.
Next episode: custom widgets. See you in the next one.