Back to Blog

egui Persistence: Save & Restore Settings with serde | Rust GUI Ep 23

Celest KimCelest Kim

Video: egui Persistence: Save & Restore Settings with serde | Rust GUI Ep 23 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.