Best Practices for the Store Plugin

Guidelines for using the Tauri v2 store plugin effectively in a React and Vite application to manage configuration, user preferences, and persistent data without introducing bugs or performance problems.

A key‑value store that survives app restarts is powerful. It is also easy to misuse—scattered writes, missing error handling, and an unclear split between configuration and runtime state can turn it from a utility into a source of bugs. These best practices give you a mental model for deciding what to put in the store, how to structure it, how to save it, and how to keep the Rust and React sides in agreement.

Deciding What to Store

Before you call store.set(...), decide which category the data belongs to. A clear separation prevents bloated stores and makes your application predictable.

Configuration Storage

Configuration is information that changes rarely, often set during onboarding or inside a settings screen. Examples:

  • API base URLs or environment identifiers (staging vs. production)
  • Feature flags that the application reads once at startup
  • Window size and position persisted across sessions
  • Theme choice (dark / light)

Because configuration is read more often than written, you can load it once when the app starts and keep it in React state. Write it back only when the user explicitly changes a setting.

A store file named config.json signals intent. The code below loads the configuration at startup and exposes it through a custom React hook. Notice that the store is loaded once and cached—creating multiple load calls for the same path is wasteful.

// src/hooks/useConfig.ts
import { load, type Store } from '@tauri-apps/plugin-store';
import { useEffect, useState } from 'react';
let configStore: Store | null = null;
async function getConfigStore(): Promise<Store> {
  if (!configStore) {
    configStore = await load('config.json', { autoSave: false });
  }
  return configStore;
}
export function useConfig() {
  const [theme, setTheme] = useState<string>('system');
  useEffect(() => {
    getConfigStore().then(async (store) => {
      const stored = await store.get<{ theme: string }>('theme');
      if (stored) setTheme(stored.theme);
    });
  }, []);
  const updateTheme = async (newTheme: string) => {
    const store = await getConfigStore();
    await store.set('theme', { theme: newTheme });
    await store.save();
    setTheme(newTheme);
  };
  return { theme, updateTheme };
}

One store per concern:

A single config.json file is usually enough for application‑wide settings. Resist the urge to create a separate store file for every preference unless you have a concrete reason—like different permission scopes or data that must never be read together.

User Preferences

User preferences are a subset of configuration tied to a specific person or profile. They may change more frequently and can include:

  • Language selection
  • Notification preferences
  • UI density or font size
  • Recently opened files or saved searches

Preferences often grow over time. Instead of storing each one as a top‑level key, nest them under a namespace. This keeps the store readable and avoids key collisions when you add new preferences later.

// Saving user preferences with a namespace
await store.set('preferences', {
  language: 'en',
  notifications: { email: true, push: false },
  fontSize: 14
});

When you read a preference, always provide a fallback. The store returns null for keys that have never been set, and your UI should not crash when that happens.

const prefs = await store.get<{ fontSize?: number }>('preferences');
const fontSize = prefs?.fontSize ?? 14;

Never assume a key exists:

A fresh installation or a cleared store will return null for every key. Defensive access with optional chaining and nullish coalescing is not optional—it is required for a working application.

Persistent Application Data

Application data is the core information your app creates or manipulates: a text editor’s open documents, a notes app’s content, a spreadsheet’s cells. This data must survive crashes and restarts, and losing it is unacceptable.

The store plugin can hold application data, but only for small to medium payloads. If you are storing hundreds of kilobytes or more, you will eventually hit performance cliffs—the store file is loaded entirely into memory, and large JSON blobs slow down every read and write.

For larger data, prefer the File System API with individual files, or a SQLite database via the SQL Plugin. The store plugin shines for metadata about those files: the list of open tabs, cursor positions, or the last active document.

// Good: store metadata, not the full content
await store.set('session', {
  openFiles: ['/documents/report.md', '/documents/notes.md'],
  activeFileIndex: 0,
  scrollPositions: { '/documents/report.md': 420, '/documents/notes.md': 0 }
});

Do not store raw file contents in the store:

A 2 MB text file pushed into the store will be serialized and deserialized on every operation. That is enough to make your UI feel sluggish. The store is an index, not a database.

Structuring Stores for Maintainability

How you name files and keys determines how quickly a new contributor understands your state. A few early decisions pay off in the long run.

Single Store vs. Multiple Store Files

Multiple store files are useful when different subsystems have different lifecycle requirements. For example, session data that is cleared on logout should live in a separate file from preferences that persist across accounts.

A common convention:

  • config.json – application‑level settings, rarely written
  • preferences.json – user‑specific choices
  • session.json – ephemeral state (cleared on sign‑out)

If you use a single store, prefix keys by domain (config.theme, session.openFiles) to achieve the same separation.

Key Naming Conventions

Flat keys like theme and language work for simple stores. As the store grows, grouping under objects reduces the mental overhead of scanning a long flat list.

Avoid deeply nested structures that require multiple levels of null checks. Two or three levels of nesting are usually enough.

// Clear and self-documenting structure
{
  "ui": { "theme": "dark", "sidebarCollapsed": false },
  "editor": { "fontSize": 14, "tabSize": 2 },
  "session": { "lastProjectPath": "/home/user/project" }
}

Integrating the Store with React and Vite

The store is asynchronous. Every call to get or set returns a promise. That means you must handle loading states and never read directly in the render body. A clean pattern is to load the store once, wrap it in a React context, and expose hooks that hide the plumbing.

1

Step 1: Create a store loader module

Load the store lazily and cache the promise so every component shares the same instance.

// src/lib/store.ts
import { load, type Store } from '@tauri-apps/plugin-store';
let storePromise: Promise<Store> | null = null;
export function getStore(): Promise<Store> {
  if (!storePromise) {
    storePromise = load('app.json', { autoSave: true });
  }
  return storePromise;
}
2

Step 2: Provide the store through context

Wrap your application with a context provider that resolves the store once and makes it available to the entire component tree.

// src/StoreContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { getStore } from './lib/store';
import type { Store } from '@tauri-apps/plugin-store';
const StoreContext = createContext<Store | null>(null);
export function StoreProvider({ children }: { children: React.ReactNode }) {
  const [store, setStore] = useState<Store | null>(null);
  useEffect(() => {
    getStore().then(setStore);
  }, []);
  if (!store) return <div>Loading store…</div>;
  return (
    <StoreContext.Provider value={store}>
      {children}
    </StoreContext.Provider>
  );
}
export function useStore() {
  const store = useContext(StoreContext);
  if (!store) throw new Error('useStore must be used within StoreProvider');
  return store;
}
3

Step 3: Build domain-specific hooks

Abstract raw get and set calls behind hooks that manage local React state and expose setters.

// src/hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react';
import { useStore } from '../StoreContext';
export function useTheme() {
  const store = useStore();
  const [theme, setTheme] = useState<string>('system');
  useEffect(() => {
    store.get<{ theme: string }>('ui').then((val) => {
      if (val?.theme) setTheme(val.theme);
    });
  }, [store]);
  const updateTheme = useCallback(
    async (newTheme: string) => {
      await store.set('ui', { theme: newTheme });
      await store.save();
      setTheme(newTheme);
    },
    [store]
  );
  return { theme, updateTheme };
}

Context‑based pattern works at scale:

If your application grows to multiple windows, the store instance can be scoped per window by passing a different file path. The context approach adapts without changing the component code.

Saving Strategies - When and How to Persist

The store plugin does not write to disk on every set. You control when persistence happens. Getting the timing right prevents data loss and unnecessary I/O.

Auto-Save with Debounce

The autoSave option accepts a number (milliseconds) or true (defaults to 100 ms). After a set call, the store waits for the debounce period before flushing to disk. If another set occurs within that window, the timer resets.

// Save automatically after 200 ms of inactivity
const store = await load('session.json', { autoSave: 200 });

This is the best default for user preferences and session state—the data persists without the developer remembering to call save(). The downside is that rapid successive writes (for example, during drag operations) still cause multiple disk flushes after the debounce settles. If you are updating a slider value 60 times per second, avoid writing to the store on every frame. Batch the final value on mouse release instead.

Debounce does not batch writes:

Each set call modifies the in‑memory state immediately. The debounce only delays the disk write. If you call set a hundred times in a loop, the store still serializes the full state once when the timer fires—but you have wasted CPU cycles updating the in‑memory tree a hundred times. Batch your changes into a single set when possible.

Manual Save for Critical Data

For data that must not be lost—like an order confirmation or the final state of a document before the user closes it—call store.save() explicitly after the write. This bypasses the debounce and forces an immediate flush. Pair this with the Process Plugin if you persist state immediately before exit or relaunch.

// Critical save after a purchase completes
await store.set('order', { id: '123', status: 'confirmed' });
await store.save();

Graceful Save on Exit

Tauri attempts to flush pending store writes when the application exits cleanly, but relying on that is fragile. If the process is killed, the debounced write never happens. For critical data, save explicitly in response to the close event or before calling app.exit().

In Rust, you can hook into the close requested event:

// src-tauri/src/lib.rs
use tauri::Manager;
use tauri_plugin_store::StoreExt;
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::default().build())
        .on_window_event(|window, event| {
            if let tauri::WindowEvent::CloseRequested { .. } = event {
                let store = window.app_handle().store("session.json").unwrap();
                // Force-flush any pending changes
                store.save().ok();
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Keeping Rust and JavaScript in Sync

When both the Rust backend and the React frontend write to the same store, one side can overwrite the other’s changes without either knowing. The store plugin itself does not provide a built‑in mechanism to notify one side when the other side modifies a value. You must build that notification channel.

The cleanest approach is to choose an owner for each key. If configuration is always set from the frontend, let the React code be the sole writer. If the backend needs to update a value—for example, refreshing an OAuth token after expiry—let Rust write to the store and then emit a Tauri event so the frontend can re‑read. Sending Events from Rust shows the emit/listen pattern.

// src-tauri/src/main.rs
use tauri::Emitter;
use tauri_plugin_store::StoreExt;
use serde_json::json;
#[tauri::command]
fn refresh_token(app: tauri::AppHandle) {
    let store = app.store("session.json").unwrap();
    let new_token = "eyJhbGciOi..."; // acquired from an HTTP request
    store.set("auth", json!({ "token": new_token }));
    store.save().unwrap();
    // Tell the frontend the store changed
    app.emit("store-updated", "auth").unwrap();
}
// src/App.tsx
import { listen } from '@tauri-apps/api/event';
import { useEffect } from 'react';
import { useStore } from './StoreContext';
function App() {
  const store = useStore();
  useEffect(() => {
    const unlisten = listen<string>('store-updated', async (event) => {
      const key = event.payload;
      const value = await store.get(key);
      console.log(`Store key "${key}" updated:`, value);
      // Update React state accordingly
    });
    return () => { unlisten.then(fn => fn()); };
  }, [store]);
  return <div>...</div>;
}

Concurrent writes without coordination corrupt data:

If the frontend calls store.set('auth', { token: 'old' }) at the same moment the backend writes a new token, one of the values will be lost. The last writer wins, and the lost update does not throw an error. Always have a single writer per key.

Permissions and Security Considerations

The store plugin ships with a set of granular permissions. In production, do not grant blanket access. The Permissions & Security chapter is the full model; the snippets below are store-specific.

Least-Privilege Permissions

Instead of enabling store:default, list only the operations your application actually needs. If your frontend never deletes keys, omit store:allow-delete.

// src-tauri/capabilities/default.json
{
  "permissions": [
    "store:allow-load",
    "store:allow-get-store",
    "store:allow-get",
    "store:allow-set",
    "store:allow-save",
    "store:allow-keys",
    "store:allow-entries"
  ]
}

This reduces the blast radius if a compromised renderer attempts to clear the store or enumerate all values.

What Not to Store

The store is a plain JSON file on disk. Any process with filesystem access can read it. Do not store:

  • Passwords, API secrets, or access tokens in plain text
  • Personally Identifiable Information (PII) that would be a compliance liability
  • Cryptographic keys

If you must persist sensitive data, encrypt it before writing to the store. The tauri-plugin-stronghold plugin provides a secure storage option, though that is a separate topic. For simple cases, the operating system’s credential manager (accessible via a custom Rust command) is a better fit.

Handling Errors and Edge Cases

Store operations can fail. The filesystem might be full, the JSON on disk might be corrupted, or the permissions might be denied. Your code must handle these cases, especially on startup.

// src/lib/store.ts – resilient loader
import { load } from '@tauri-apps/plugin-store';
export async function safeLoadStore(path: string) {
  try {
    return await load(path);
  } catch (err) {
    console.error(`Failed to load store "${path}":`, err);
    // Optionally reset the store or fall back to defaults
    return null;
  }
}

When a get call returns null for a key that should contain an object, your code must treat that as a normal initialization scenario—not a bug.

Corrupted store files are rare but possible if the application crashes mid‑write. A production application should catch deserialization errors during load and either fall back to defaults or attempt to repair the file. The store plugin does not do this automatically.

A corrupted store can block your app:

If load throws an unhandled exception, your application’s startup sequence may halt. Wrap the initial store access in a try/catch and show a graceful error screen instead of crashing.

Migrating Data Between App Versions

Application updates sometimes change the shape of stored data. An old store file with a previous schema can cause errors if your new code expects a different structure.

Add a version key to each store file. On load, check the stored version against the expected version and run migration functions if they differ.

const EXPECTED_VERSION = 2;
async function migrate(store: Store) {
  const version = await store.get<number>('version');
  if (!version || version < 2) {
    // Run migration logic: rename keys, add defaults, etc.
    await store.set('ui', { theme: 'dark' }); // new key
    await store.set('version', EXPECTED_VERSION);
    await store.save();
  }
}

This pattern costs almost nothing to implement early and prevents a class of support tickets where users lose their settings after an update.

Common Mistakes

Calling load repeatedly for the same file. The plugin reuses the underlying store if the path matches, but creating multiple load calls still adds unnecessary async work and makes it harder to reason about which instance holds the latest data. Load once and share the reference.

Reading from the store in the render body without a loading state. The store is asynchronous; you must use useState + useEffect or a similar pattern to bridge the promise into synchronous React state. Directly awaiting in the component function body will cause infinite re-renders.

Using set in a tight loop. Updating the store sixty times per second during a mouse drag will cause visible jank. Collect the final value and write it once when the interaction ends.

Ignoring the return value of save. If save rejects, the write did not happen. Logging the error is not enough—show a notification to the user or retry.

Storing UI ephemera. Animation states, hover effects, and form input values that are meaningless after a restart do not belong in the store. Use React state for those.

You know you are on the right track when:

Your store file remains under 100 KB, every key has a clear owner, and your application starts without errors even if the store file is missing entirely.

Summary

The store plugin is a small but sharp tool. Use it for configuration, preferences, and lightweight session state; keep it structured with clear namespaces; decide who writes each key; and always handle the case where data is missing or corrupted.

The patterns in this chapter—cached store instances, domain‑specific hooks, versioned migration, and explicit save calls—form a foundation that scales from a single‑window utility to a multi‑window application without requiring a rewrite.