Tauri Patterns for Production: Persist App State to Disk in Tauri 2
Video: Persist App State to Disk in Tauri 2 | Store Plugin Tutorial by CelesteAI
You have a Tauri 2 app. The user changes a setting, increments a counter, picks a theme — whatever. They quit the app. They reopen it. The state is gone.
This is the smallest possible problem you will hit in any real desktop app, and it has a one-paragraph answer in Tauri 2: install tauri-plugin-store, register it in lib.rs, add store:default to your capability config, call load("name.json") from your React code. JSON gets written to the OS app data directory. Survives restarts. Done.
This tutorial walks through the whole loop with a deliberately minimal demo — a counter that survives every relaunch — and unpacks the few patterns that matter when you start using the store for real settings, not toy values.
What “persistent state” actually means in Tauri
When your Tauri app quits, everything inside the WebView process dies. That includes React state, every variable, every cached value. The next launch is a fresh process — useState(0) puts the count back at zero. Nothing about Tauri itself is wrong; the same thing happens in any browser tab when you close it.
To “remember” anything, the app has to write to a place that outlives the process. Three places are commonly viable on the desktop:
- A JSON file on disk. Smallest API, no schema. Best for settings, preferences, last-opened paths, small bags of state.
- An embedded database (SQLite). Schema, queries, indexes. Best when you have multi-row data — todos, history, documents.
- A key-value store via the OS keychain. Best for secrets — API tokens, credentials. Encrypted at rest, but the API is awkward for plain data.
tauri-plugin-store is the canonical answer for case 1. It writes JSON to the OS app data directory using a path derived from your tauri.conf.json identifier. On macOS that’s ~/Library/Application Support/<bundle id>/; on Linux ~/.local/share/<bundle id>/; on Windows %APPDATA%/<bundle id>/. You don’t compute the path — the plugin does, correctly, per OS.
If your “state” fits comfortably in a JSON file under a megabyte, the store plugin is the right default. Don’t reach for SQLite to remember the user’s theme.
The demo: Persisto
We are building Persisto — a counter app with three buttons. Increment, decrement, reset. Quit. Reopen. The count is still there.
The whole app is one App.tsx, one Rust line in lib.rs, and one capability entry. It is the shortest possible end-to-end example of tauri-plugin-store.
demo-app/persisto/
├── package.json ← adds @tauri-apps/plugin-store
├── src-tauri/
│ ├── Cargo.toml ← adds tauri-plugin-store
│ ├── tauri.conf.json
│ ├── capabilities/default.json ← adds store:default
│ └── src/lib.rs ← registers the plugin
└── src/App.tsx ← load → get → setState → set
We are skipping the pnpm create tauri-app part — assume you already have a working Tauri 2 + React + TypeScript app. Adding the store plugin to an existing app takes about thirty lines.
Step 1 — Install the plugin (both sides)
tauri-plugin-store has two halves: a Rust crate and a JavaScript package. You install both.
Rust side — add to src-tauri/Cargo.toml:
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
JavaScript side — add to package.json and install:
pnpm add @tauri-apps/plugin-store
That’s it for installation. serde and serde_json were already there from the Tauri template — the store plugin uses them internally, no extra config needed.
Step 2 — Capability config: the permission gate
Tauri 2’s defining feature is the capability system: every plugin’s API is gated by an explicit permission entry. If you don’t add the permission, the JS call to load() will throw at runtime with a “permission not granted” error. This is by design — Tauri’s threat model assumes the WebView is hostile, so every capability is opt-in.
Open src-tauri/capabilities/default.json and add store:default:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"store:default"
]
}
store:default is a permission set that bundles the common store operations: load, get, set, save, delete, clear, has, keys, values, entries, length, reload, onChange, onKeyChange.
If your production threat model needs fine-grained gating, the plugin ships individual permissions: store:allow-load, store:allow-get, store:allow-set, etc. For a single-user desktop app, store:default is fine.
Step 3 — Register the plugin in lib.rs
The Rust side just needs to chain .plugin() into the builder:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
That single line is the entire Rust-side integration. The plugin handles:
- File paths (per OS).
- Atomic writes (writes to a temp file, then renames, so a crash mid-write never corrupts the JSON).
- Serialization of any serde::Serialize type.
- Notification of in-process listeners on change.
You never see std::fs or serde_json::to_string in your own code. The plugin owns that.
Step 4 — The React side: load, get, set
Here is the entire App.tsx:
import { useEffect, useState } from "react";
import { load, Store } from "@tauri-apps/plugin-store";
import "./App.css";
export default function App() {
const [store, setStore] = useState<Store | null>(null);
const [count, setCount] = useState(0);
useEffect(() => {
load("counter.json", { defaults: { count: 0 }, autoSave: true }).then(async (s) => {
const saved = await s.get<number>("count");
setCount(saved ?? 0);
setStore(s);
});
}, []);
const update = async (next: number) => {
setCount(next);
await store?.set("count", next);
};
return (
<main className="container">
<h1>Persisto</h1>
<p className="muted">Count survives restarts.</p>
<div className="count">{count}</div>
<div className="row">
<button onClick={() => update(count - 1)}>−</button>
<button onClick={() => update(count + 1)}>+</button>
<button onClick={() => update(0)}>Reset</button>
</div>
</main>
);
}
Walk through the moving parts:
load("counter.json", options)
load() opens (or creates) a store file at <app_data_dir>/counter.json. The file name is your handle — pick something descriptive (settings.json, recent.json, window-state.json) and pass it consistently.
Two options matter:
- defaults — the initial shape of the store on first run. If counter.json doesn’t exist yet, the plugin creates it with these values. After that, the defaults are ignored. They are not a fallback for missing keys; they only seed the first-ever write.
- autoSave: true — every set flushes to disk after a short debounce (100ms by default; passing a number tunes the debounce). Without this, you must call await store.save() manually after every write. Forget once, and a write is lost when the user quits.
Use autoSave: true for almost every store. The only reason to turn it off is if you’re writing in a hot loop and want to batch flushes — but for that case, the SQL plugin is usually a better fit anyway.
store.get<T>(key)
Returns the stored value, or undefined if the key is missing. The generic parameter <T> types the return; the plugin does no runtime validation, so if the file on disk has the wrong shape, you’ll get a typed-but-incorrect value back. For untrusted input or migration scenarios, add Zod or io-ts on top.
saved ?? 0 is the safety net: if the store is missing the count key for any reason (corrupted file, fresh install before defaults seed), fall back to zero.
The load-then-render pattern
useEffect runs once on mount. We load the store, read the saved value, then setCount(saved ?? 0) seeds React state from disk. Until that resolves, count is the initial useState(0) value.
In a real app you’d usually show a loading state or short-circuit the render until store is non-null — otherwise the user sees a one-frame flash of 0 before the real value paints. For a counter it’s invisible; for a richer settings page it can be jarring. The shape to remember is:
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
load(...).then(async (s) => {
// seed all state from s.get(...)
setHydrated(true);
});
}, []);
if (!hydrated) return <Spinner />;
Write-through on every change
update(next) does two things: update React state, then write to the store. The React state update is synchronous; the store write is await-ed so it returns when the in-memory store is updated (the disk flush happens asynchronously via autoSave).
Optional chaining (store?.set(...)) handles the edge case where the user clicks a button before the store finishes loading. In practice the load completes within tens of milliseconds, but the guard costs nothing.
Step 5 — Run it and watch the proof
pnpm tauri dev
The counter renders at 0 (first run, no counter.json exists yet). Click + a few times — the count goes up, and each click writes counter.json to the app data directory. Quit the app entirely (Cmd+Q on macOS, X on Windows/Linux). Reopen. The count is exactly where you left it.
On macOS you can inspect the file directly:
cat "~/Library/Application Support/com.codegiz.persisto/counter.json"
# {"count":3}
Two lines. That’s the entire state. Nothing about SQLite, nothing about IndexedDB, no broker between the WebView and disk.
Patterns that scale
The counter is a toy. The patterns generalize:
One store per concern, not one big bag
It’s tempting to have a single state.json with every key your app cares about. Don’t. Use one file per concern:
~/Library/Application Support/com.codegiz.myapp/
├── settings.json — theme, language, telemetry opt-in
├── recent.json — recent file paths
├── window-state.json — last window size and position
└── auth-cache.json — non-secret auth metadata (no tokens!)
Reasons:
- Corruption isolation. A truncated recent.json doesn’t take settings down with it.
- Reads are scoped. You don’t need to load and walk a 50-key blob to read one value.
- Concurrency is per-file. Two unrelated subsystems can write at the same time without contention.
Tauri’s store plugin scales fine to many small stores — there’s no global lock, no rate limit, and each file is opened lazily.
Never store secrets in the store
The store writes plain JSON to a user-readable directory. Anything in it is visible to any process running as the same user. Treat the store as “not secret” — fine for settings, theme, telemetry preferences, recently-used paths. Not fine for auth tokens, API keys, encryption keys, or anything you’d be embarrassed to see in cat.
For secrets, use the OS keychain via a separate plugin (tauri-plugin-stronghold is one option). The threat model is “another process on the user’s machine reads the store file” — the store is not encrypted at rest.
Write-through state, not write-back
The pattern above writes to the store on every change. This is “write-through” — the store is always at most one set-operation behind the in-memory state.
The opposite — “write-back” — would batch writes (e.g., flush every 30 seconds). It’s tempting for very chatty state, but it’s a footgun: any crash, kernel panic, or forced quit during the gap loses the unflushed writes. Unless you have measured a real performance problem, prefer write-through. The disk is fast and autoSave debounces sensibly.
Migrations (when the schema changes)
Eventually you’ll want to rename a key, change a value’s type, or restructure. tauri-plugin-store has no migration framework — it’s a plain JSON file. You handle migrations in the load callback:
const s = await load("settings.json", { defaults: { ... }, autoSave: true });
const version = (await s.get<number>("__version")) ?? 0;
if (version < 1) {
// migrate from v0 → v1
const oldTheme = await s.get<string>("themeName");
if (oldTheme) {
await s.set("theme", { name: oldTheme, mode: "light" });
await s.delete("themeName");
}
await s.set("__version", 1);
}
A __version key inside the store, checked on load, applied in code. Simple, explicit, no framework.
What you didn’t have to think about
Look at what the store plugin handles invisibly:
- Cross-platform paths. No
if (platform === "win32") .... - Atomic writes. Crash mid-write, the file is either the old version or the new version, never a corrupted half.
- Permission gates. The capability config is the only access control surface — no separate “allow file read” / “allow file write” plumbing.
- Serialization.
serdeandserde_jsondo the work; you pass arbitrary serializable values. - Debounced flushes. autoSave batches rapid writes without exposing the batching to your code.
This is the value of plugin systems in Tauri: each plugin is a tightly-scoped, well-tested chunk of platform-specific code that you would otherwise re-implement (badly) yourself.
When the store is not the right answer
Two cases:
1. You need queries. If you’re storing more than a flat list — anything with relationships, filtering, or indexes — switch to tauri-plugin-sql (SQLite). The store has no query language. Looping over store.entries() and filtering in JS works for tens of items, not for thousands.
2. The data is secret. As mentioned above, the store is not encrypted. For API tokens, auth state, anything you wouldn’t put in ~/.bashrc, use the OS keychain via a different plugin.
For everything else — settings, preferences, UI state, window geometry, last-used paths, telemetry opt-in, theme, language — the store plugin is the right default. Reach for it first.
Summary
tauri-plugin-store is the smallest, most boring, most useful plugin in the Tauri ecosystem. Install on both sides, register one line in lib.rs, add one capability entry, call load() with autoSave: true. That’s it.
The interesting parts come later: one store per concern, never secrets, write-through over write-back, and a __version key when the schema evolves. None of that is in the plugin — it’s how you use the plugin.
The next time you reach to wire up localStorage, electron-store, or a hand-rolled fs.writeFileSync in a Tauri app — don’t. There’s a one-line plugin for that.
Full source for the demo is on GitHub. The same pattern works in any Tauri 2 app.