State management for Rust GUI apps — persistence, reactivity, and migrations without the boilerplate.
Why amethystate
Everything you would write yourself — persistence, subscriptions, migrations — handled for you.
Writing a field persists it. Debounced flush to disk happens in the background automatically.
Subscriptions fire per-field. Compose fields into derived pipelines that recompute automatically.
Version your structs, write a migration step. Runs automatically on startup.
egui, iced, Dioxus, Leptos, Slint, GPUI, Tauri — each with a dedicated integration.
How it works
Annotate a struct with #[amethystate], set a prefix and a version. Fields persist automatically on every write.
In reactive mode each field is a Field<T> handle — read, write, subscribe. In persistent-only mode fields are plain Rust types — mutate them directly, call save() when done.
Schema drift is detected on startup. If you renamed a field without bumping the version, you get a warning with a diff — not a crash.
#[amethystate(prefix = "network", version = 1)]
pub struct NetworkState {
#[amestate(default = "127.0.0.1".to_string())]
pub host: String,
#[amestate(default = 8080)]
pub port: u16,
}
let state = NetworkState::new()?;
state.port().subscribe(|p| println!("port → {p}"));
state.port().set(9090)?; Integrations
Pick your framework — the integration handles the bridge between amethystate fields and framework-native signals or update loops.
// persistent-only mode
#[amethystate(prefix = "app", mode = "persistent")]
pub struct AppState { pub theme: String }
let mut state = AppState::load()?;
state.theme = "dark".to_string();
state.save_lazy()?; #[amethystate(prefix = "app", mode = "persistent")]
pub struct TuiSettings {
pub username: String,
pub theme: String,
}
let mut state = TuiSettings::load_with(&store)?;
loop {
let username = &state.username;
let theme = &state.theme;
terminal.draw(|f| {
let text = vec![
Line::from(format!("Username: {username}")),
Line::from(format!("Theme: {theme}")),
];
f.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
f.area(),
);
})?;
} #[amethystate(prefix = "app", mode = "persistent")]
pub struct AppState { pub counter: i32 }
fn update(state: &mut AppState, msg: Message) {
state.counter += 1;
state.save_lazy().ok();
} #[component]
fn Counter(state: Handle<AppSettings>) -> Element {
let (count, set_count) = use_field(state.counter);
rsx! {
button { onclick: move |_| set_count(count() + 1), "count: {count}" }
}
} #[component]
fn Settings(state: Handle<AppSettings>) -> impl IntoView {
let (username, set_username) = use_field(state.username);
view! {
<input prop:value=username on:input=move |e| set_username.set(event_target_value(&e)) />
}
} #[function_component(Counter)]
fn counter(props: &CounterProps) -> Html {
let (count, set_count) = use_field(props.state.counter.clone());
html! {
<button onclick={Callback::from(move |_| set_count.emit(count + 1))}>
{format!("count: {count}")}
</button>
}
} import { AppSettings } from "./bindings/amethystate";
const settings = await AppSettings.load();
console.log(settings.username.value);
settings.username.value = "Alice";
await settings.save(); impl CounterView {
fn new(cx: &mut Context<Self>) -> Self {
Self { state: cx.new_amethystate(CounterState::new).unwrap() }
}
}
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("Count: {}", self.state.read(cx).count().get()))
} Migrations
Bump the version, write a step function. The migrator chains them in order and runs on startup before any app code touches the store.
v1
host: String
port: u16 v2
address: String
port: u16
timeout_ms: u64 #[migrate]
#[rename(host => address)]
fn migrate_config_v1_to_v2(
old: AmeData<v1::Config>,
) -> amethystate::MigrationResult<AmeData<Config>> {
Ok(AmeData::<Config> {
address: old.host,
port: old.port,
timeout_ms: 5000,
})
}