DEV Community

uknowWho
uknowWho

Posted on

Tauri Framework: Code-First Deep Dive : E1

Image description

Table of Contents

Image description

Architecture Overview
Tauri follows a multi-process architecture where the frontend (WebView) and backend (Rust) run in separate processes, communicating through secure IPC channels.

┌─────────────────┐ IPC Bridge ┌─────────────────┐
│ Frontend │ ◄──────────────► │ Backend │
│ (WebView) │ │ (Rust Core) │
│ │ │ │
│ - HTML/CSS/JS │ │ - System APIs │
│ - UI Logic │ │ - File I/O │
│ - User Events │ │ - Network │
└─────────────────┘ └─────────────────┘

Project Setup & Configuration

  1. Initialize Tauri Project

Install Tauri CLI

cargo install tauri-cli

Create new project

cargo tauri init

Or with existing frontend

npm create tauri-app@latest

  1. Core Configuration (tauri.conf.json) { "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", "devPath": "http://localhost:3000", "distDir": "../dist" }, "package": { "productName": "MyTauriApp", "version": "0.1.0" }, "tauri": { "allowlist": { "all": false, "fs": { "all": false, "readFile": true, "writeFile": true, "createDir": true, "scope": ["$APPDATA/myapp/*", "$HOME/Documents/*"] }, "dialog": { "all": false, "open": true, "save": true }, "shell": { "all": false, "execute": true, "sidecar": true, "scope": [ { "name": "my-script", "cmd": "python", "args": ["./scripts/my-script.py"] } ] } }, "security": { "csp": "default-src 'self'; connect-src ipc: https:; script-src 'self' 'unsafe-inline'" }, "windows": [ { "fullscreen": false, "resizable": true, "title": "My Tauri App", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600 } ] } }
  2. Rust Backend Structure (src-tauri/src/main.rs) 🔧 Top-level Setup and Imports

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

What it does:
This conditional attribute hides the console window on Windows only in release builds (not(debug_assertions)).

Why it matters:
Makes your app feel like a native GUI app (no terminal popping up).

use tauri::{Manager, State, Window};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
tauri::{Manager, State, Window}:

Manager:
Allows managing app state, windows, emitting events, etc.
State: For passing shared state (like a database or counter).
Window:
Interface to a specific app window.
std::sync::Mutex: Thread-safe mutable access to shared data.
serde::{Deserialize, Serialize}: Lets you serialize/deserialize structs (needed to pass state/data between Rust and JS).
📦 AppState Struct
#[derive(Debug, Serialize, Deserialize)]
struct AppState {
counter: Mutex<i32>,
user_data: Mutex<Vec<String>>,
}

AppState: A globally managed struct holding:
counter: shared, mutable counter.
user_data: a list of user-provided strings.
Mutex<>: Ensures safe concurrent access from multiple threads.
Derives Serialize/Deserialize: So it can be passed to/from the frontend.

impl Default for AppState {
fn default() -> Self {
Self {
counter: Mutex::new(0),
user_data: Mutex::new(Vec::new()),
}
}
}

Implements Default for AppState, allowing you to easily instantiate it via AppState::default() during app startup.

🚀 Main Function — Tauri App Lifecycle
fn main() {

  1. App Builder Initialization tauri::Builder::default() Starts configuring your Tauri app with default settings.
  2. Manage Global State .manage(AppState::default())

Injects your AppState struct into Tauri’s dependency injection system so it can be accessed in commands (via State)

  1. Register Commands

.invoke_handler(tauri::generate_handler![
greet,
increment_counter,
get_counter,
read_config_file,
write_config_file,
get_system_info,
emit_custom_event
])

Registers backend Rust commands callable from the frontend (via window.TAURI.invoke).
These functions (e.g. greet, increment_counter, etc.) should be defined in your Rust code elsewhere.

  1. Setup Hook

.setup(|app| {
let app_handle = app.handle();

Runs once when the app starts.
You get the app_handle to manage app state, windows, etc.

  1. Ensure App Data Directory Exists

` let app_data_dir = app.path_resolver()
.app_data_dir()
.expect("Failed to get app data directory");

        if !app_data_dir.exists() {
            std::fs::create_dir_all(&app_data_dir)?;
        }`
Enter fullscreen mode Exit fullscreen mode

Gets the path to the platform-specific app data directory (e.g., ~/Library/Application Support/MyApp).
If it doesn’t exist, creates it.
Useful for storing config files, logs, etc.

  1. Complete Setup

Ok(())
})

Return Ok(()) to signal setup success.

  1. Run the App .run(tauri::generate_context!()) .expect("error while running tauri application"); } Loads Tauri’s runtime context (from tauri.conf.json). Boots the app. If anything goes wrong, logs an error. 🔁 System Overview: Visual Breakdown

🧠 Business & Dev Benefits

IPC Communication Patterns

  1. Command Pattern (Frontend → Backend) Rust Backend Commands:

Imagine you’re building a desktop app with Tauri. You want the frontend (JavaScript) to talk to Rust for real work: counting clicks, processing data, etc.

Tauri gives you a secure channel (invoke) to call Rust functions from the frontend. These Rust functions are called commands. They can do logic, manage state, and return results.

🌟 SECTION 1: Understanding #[command]

[command]

This is a macro provided by Tauri.
It tells Tauri:
“Hey, this Rust function is safe to be called from JavaScript!”

So if your frontend JS does:

await invoke("greet", { name: "Alice" });

Tauri will look for a Rust function annotated with #[command] and named greet. ✅

🧠 1. greet Function — The Hello World of Commands
`

[command]

async fn greet(name: String) -> Result {
Ok(format!("Hello, {}! You've been greeted from Rust!", name))
}`

🧠 What’s going on here?

🔁 Flow:
JS sends a string "Alice" → passed as name
Rust formats it → returns "Hello, Alice! ..."
If error occurred, return Err("some error")
💡 Analogy: JS is placing a phone call to Rust. #[command] answers, and the function returns a spoken reply.

🧠 2. increment_counter
#[command]
async fn increment_counter(state: State<'_, AppState>) -> Result<i32, String> {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
Ok(*counter)
}

🔍 Let’s decompose this Feynman-style:
🧱 state: State<'_, AppState>

Think of State as a shared box passed around the app.
Inside the box: a Mutex named counter.
'_' is just shorthand for a lifetime (safe borrow).
🧲 lock().unwrap()
You want to open the box and get mutable access.
Since it’s protected by a Mutex, you must lock() it.
If locking fails (e.g. deadlock), unwrap() panics (for simplicity here).
🧠 Mutation
*counter += 1;
Dereferences the mutex-guarded value, adds 1.
📤 Return the new counter value
“Hey JS, the counter is now: 7”

🧠 3. get_counter
#[command]
async fn get_counter(state: State<'_, AppState>) -> Result<i32, String> {
let counter = state.counter.lock().unwrap();
Ok(*counter)
}

Same idea, but read-only access.
Lock the mutex and return the inner value.
🧠 Analogy: You’re peeking inside a locked chest (read-only), not modifying it.

🧠 4. process_data
This one is more complex, so let’s Feynman it step-by-step.

#[command]
async fn process_data(
data: Vec<String>,
options: ProcessOptions,
state: State<'_, AppState>
) -> Result<ProcessResult, String> {

🧩 What are the inputs?
data: A list of strings from the frontend (e.g., ["hi", "hello", "world"])
options: Some settings from frontend (how to process)
state: Global app state, holds user_data
🧠 Step 1: Process the input list

let processed = data.iter()
.filter(|item| item.len() > options.min_length)
.map(|item| item.to_uppercase())
.collect();

Loop through all the strings:
Only keep those with length > min_length
Convert them to UPPERCASE
Example:
Input: ["hi", "hello", "world"], min_length = 2 → Output: ["HELLO", "WORLD"]
🧠 Step 2: Update Shared State
let mut user_data = state.user_data.lock().unwrap();
user_data.extend(processed.clone());
Lock the global user_data vector (mutex).

Append the new results to it.
🧠 Step 3: Return a result struct
Ok(ProcessResult {
processed,
total_count: user_data.len(),
timestamp: chrono::Utc::now().timestamp(),
})

Return:

The processed strings
Total number of strings saved in global state
Current UTC timestamp (useful for logging/metrics)
🧠 5. Data Structures

#[derive(Deserialize)]
struct ProcessOptions {
min_length: usize,
max_items: Option<usize>,
}

Deserialize = frontend can send this struct
Example JS object:
{
min_length: 3,
max_items: null
}

#[derive(Serialize)]
struct ProcessResult {
processed: Vec<String>,
total_count: usize,
timestamp: i64,
}

Serialize = can send this back to frontend
Returned as JSON object like:
{
"processed": ["HELLO", "WORLD"],
"total_count": 7,
"timestamp": 1723462781
}

🧠 Summary: Big Picture Mental Mode

Frontend TypeScript Integration:

// src/lib/tauri-api.ts
import { invoke } from '@tauri-apps/api/tauri';
🔄 What does invoke() do?

It lets JS/TS call a Rust function marked with #[command].
You pass:

A command name as a string ("increment_counter")
A payload (object containing parameters)
Think of it like calling a function in another language via a “network wire” inside your computer.

🧠 Part 1: Understanding the TauriAPI Class
export class TauriAPI {
This is a wrapper class. It wraps the low-level invoke() calls into readable, clean, typed functions.

🧱 Let’s go method-by-method
🧠 greet(name: string): Promise

return await invoke('greet', { name });
Calls Rust’s greet function.
Sends { name: "Alice" }
Rust returns "Hello, Alice!"
Fully type-safe: we tell TypeScript what kind of value is coming back.
JS hands Rust a note with your name, and Rust replies with a greeting.

🧠 incrementCounter()
return await invoke<number>('increment_counter');
Calls Rust’s increment_counter
No arguments
Rust locks a shared counter, increments it, and returns new value
It’s like clicking a tally clicker. Rust holds the clicker securely; you ask it to click once and tell you the total.

🧠 getCounter()
return await invoke('get_counter');
Calls Rust’s get_counter to read current count
No side-effects, just reads
Returns i32 from Rust (number in TS)
Analogy: You’re asking Rust, “Hey, what’s the number on the clicker right now?”

🧠 processData(data: string[], options: ProcessOptions)
return await invoke('process_data', {
data,
options: {
min_length: options.minLength,
max_items: options.maxItems,
}
});

🧬 Dissecting the Arguments
Sends two things to Rust:

data: an array of strings
options: an object shaped like ProcessOptions, but converted to Rust naming style (snake_case)
data: Vec,
options: ProcessOptions // where min_length, max_items are field names
You’re giving Rust a bunch of data and some instructions:
“Keep only long items. UPPERCASE them. Track the result in global memory. Also, tell me the timestamp.”

🧠 Part 2: TypeScript Interfaces
🔍 ProcessOptions
export interface ProcessOptions {
minLength: number;
maxItems?: number;
}
JS representation of Rust’s ProcessOptions
Optional maxItems means it can be undefined, just like Option in Rust
These are the settings you pass to Rust to tweak how the data is processed.

🔍 ProcessResult
export interface ProcessResult {
processed: string[];
totalCount: number;
timestamp: number;
}
JS version of the ProcessResult Rust struct

Includes:

Transformed data
Number of total entries in global memory
When the processing occurred
🎨 Part 3: React Component — CounterComponent
💡 Purpose
This UI shows:

The current count
A button to increment the counter
Uses React hooks (useState, useEffect)
🔬 Deconstructing it
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
count: Holds the current number from Rust
loading: Button shows spinner when async call is in progress
🧠 handleIncrement — Clicking the button
const handleIncrement = async () => {
setLoading(true);
try {
const newCount = await TauriAPI.incrementCounter();
setCount(newCount);
} catch (error) {
console.error('Failed to increment:', error);
} finally {
setLoading(false);
}
};

When the button is clicked:
Show spinner
Call Rust to increment
Set the new value into state
Hide spinner
You’re telling Rust: “Count one up.” When it replies, update your screen.

🔁 useEffect — on first load
useEffect(() => {
TauriAPI.getCounter().then(setCount);
}, []);
On mount ([] = run once):

Ask Rust for current count
Show it
Think of this like bootstrapping your React app with the server-side state from Rust.

🖼️ Render:
return (
<div className="counter">
<h2>Count: {count}</h2>
<button onClick={handleIncrement} disabled={loading}>
{loading ? 'Loading...' : 'Increment'}
</button>
</div>
);

Shows the current count
Button says:

“Increment” when idle
“Loading…” when it’s waiting on Rus
🧠 Concept Map: JavaScript → Tauri → Rust

🧠 Summary: What You’ve Built

  1. Event Pattern (Backend → Frontend) Rust Event Emission:

A Tauri backend command that:

Runs a long task in the background (without freezing the UI)
Sends progress updates to the frontend every step
Notifies when it’s complete
Like a pizza oven that updates the UI on how many slices it’s baked so far. 🍕

// src-tauri/src/events.rs
`use tauri::{command, Manager, Window};
use serde::Serialize;

  1. ✅ The Data Structure — ProgressUpdate #[derive(Serialize, Clone)] struct ProgressUpdate { current: u32, total: u32, message: String, }` Let’s deconstruct:

[derive(Serialize, Clone)]

Serialize: Makes this struct convertible to JSON (so it can be sent to the JS frontend)
Clone: Allows the struct to be copied easily in memory
Think of this as adding a “JSON jacket” and “photocopier” to your data structure so it can be broadcast to the frontend and reused.

struct ProgressUpdate
Rust’s way of defining a data container, like a JS object { current, total, message }
current: u32 // how far we are
total: u32 // the total goal
message: String // optional human-readable message

  1. 🚀 The Async Command — start_long_task #[command] async fn start_long_task(window: Window) -> Result<(), String> { #[command] Tauri magic. Tells Tauri: “Hey, this is callable from the JavaScript side!”

async fn
This is an asynchronous function in Rust, meaning it can:

Wait
Sleep
Yield to other tasks
Without blocking the whole app
Like saying: “This function may take a while — let it cook in the background.”

window: Window
This is Tauri’s handle to the UI window (like a messenger line to the frontend)

Think of it as a walkie-talkie to the JavaScript world.

-> Result<(), String>
It returns:

Ok(()) if successful
Err(String) if it fails (with a human-readable error)

  1. 🧠 Spawn Background Work let window_clone = window.clone(); tokio::spawn(async move { ... }); window.clone() We clone the window because tokio::spawn moves ownership into another thread. Rust doesn’t allow two owners by default — but cloning safely gives another copy. “We photocopy the walkie-talkie so our background task can still talk to the frontend.”

tokio::spawn(async move { ... })
Spawns a new green thread using Tokio (Rust’s async runtime)
Runs in the background, without blocking the main thread
Like launching a robot that bakes the pizza while we continue chatting.

  1. 🔁 Simulate Work + Emit Progress
    for i in 1..=100 {
    tokio::time::sleep(Duration::from_millis(50)).await;
    for i in 1..=100
    Loop from 1 to 100 inclusive
    Each i represents one step of progress
    tokio::time::sleep(...)
    Pauses 50ms per step to simulate a long task
    await lets other work happen during the sleep
    Like baking 1 pizza slice every 50ms

  2. 📡 Emit a Progress Event
    let progress = ProgressUpdate {
    current: i,
    total: 100,
    message: format!("Processing item {}/100", i),
    };

    window_clone.emit("task-progress", &progress)?;
    We create a ProgressUpdate instance with:

current = i
total = 100
a message string
emit("task-progress", &progress)
Sends an event to the JS frontend
task-progress is the event name (frontend listens to this!)
&progress is the data — converted to JSON because of Serialize
Think of it like Rust yelling to JS:
“Hey! I’m at 37 out of 100! Here’s your update!”

.map_err(...)
If emitting fails, convert the error into a string so we can bubble it back cleanly.

  1. 🎉 Emit Final Completion Event
    window_clone.emit("task-complete", "Task finished successfully!")?;
    When loop is done, send one last signal: “I’m done!”
    JS receives this and can show a toast or stop a loading bar.

  2. 📤 Top-Level Function Still Returns Immediately
    Ok(())
    Important: start_long_task() returns right after spawning the task.
    You don’t wait for the task to complete here.
    “Start the baking robot and return immediately — the robot will keep updating us as it cooks.”

  3. 📡 Manual Event Emission — emit_custom_event

    [command]

    async fn emit_custom_event(window: Window, event_name: String, payload: String) -> Result<(), String> {
    window.emit(&event_name, payload)?;
    Ok(())
    }
    This is a utility command that lets JS trigger any custom event on itself.

JS calls this with:

await invoke("emit_custom_event", { event_name: "ping", payload: "Pong from backend" });
It’s like letting the frontend trigger backend-powered frontend events for flexibility.

Frontend Event Handling:

This is what you’re building:

A listener system that reacts to messages (called events) like:

“a task is progressing…”
“a task is complete!”
“something went wrong!”
A React hook that listens to these events and gives live updates to your UI. It’s like setting up walkie-talkies between a worker (Rust, backend, etc.) and a dashboard (React). When the worker yells “Hey! I finished slice 5!”, the dashboard updates the progress bar.

Now let’s get into more detail work

🔍 First: Understanding the Interfaces
export interface ProgressUpdate {
current: number;
total: number;
message: string;
}

This defines the shape of a progress update — like a schema or a “form” it must fill.

Think of this like:

✅ current: What slice are we at?

✅ total: How many total slices are expected?

✅ message: A human-readable update like "Now slicing 5/100"

🔊 EventHandler.listenToProgress()
static async listenToProgress(callback: (progress: ProgressUpdate) => void)
This function listens to progress updates and runs your callback function every time a new one comes in.

Under the hood:

const unlisten = await listen('task-progress', (event) => {
callback(event.payload);
});
listen: Listens for messages tagged "task-progress" that must follow the ProgressUpdate structure.
event.payload: The actual message content.
Then it returns:

return unlisten;
Which is a function you can call later to stop listening.

🧠 Think of it as subscribing to a channel, and you get back a remote that lets you mute it.

✅ listenToCompletion()
Same idea but simpler — just listens for when the task is complete:

static async listenToCompletion(callback: (message: string) => void)
Listens to 'task-complete'
Runs your callback with a string (e.g., "done!")
🛠️ setupGlobalEventListeners()
static async setupGlobalEventListeners(): Promise void>>
This sets up miscellaneous event listeners, like:

'system-event': Might log version updates, metadata, etc.
'error': Something went wrong.
Each one returns an unlistener that you store in a Map, like:

unlisteners.set('error', errorUnlisten);
Later, if you want to clean everything up, you can just call each stored function.

🧠 Think of this like setting up house alarms and storing the off switches in a labeled box.

🔁 useTaskProgress() (React Hook)
This lets a React component automatically track progress and know when the task is complete.

Code breakdown :

const [progress, setProgress] = useState(null);
const [isComplete, setIsComplete] = useState(false);
Two states:

progress: How far we’ve gone.
isComplete: Has the task finished?
Inside useEffect:
let progressUnlisten: (() => void) | null = null;
let completeUnlisten: (() => void) | null = null;
Create two unlisten holders so we can clean up later.

progressUnlisten = await EventHandler.listenToProgress(setProgress);
completeUnlisten = await EventHandler.listenToCompletion(() => {
setIsComplete(true);
setProgress(null);
});
We connect to those event channels and tell them what to do:

🧹 Then this part cleans up when the component unmounts:

return () => {
progressUnlisten?.();
completeUnlisten?.();
};
🧠 Mental Model Summary
Imagine:

🔍 First-Principles Breakdown
Why use events?

Because you want to decouple the producer (Rust/backend) from the consumer (UI). Instead of the UI constantly asking, “are we done yet?”, it waits passively for updates.

Why store unlisten functions?

Because you want full cleanup control. React components mount/unmount, and without cleanup, you’ll get memory leaks or duplicate listeners.

Why use a hook?

It makes the logic reusable, declarative, and idiomatic in React. Any component can just useTaskProgress() and instantly get the live state.

Security & Sandboxing
[App Launch]
└► initSecurityPlugin(app)
└ sets up global listener for "tauri://created"

[Window Created]
└► global listener triggers
└► window.setTitle("Secure Tauri App")

[Frontend JS]
└► invoke("security|validate_content_security", content)
└► Rust runs command
└ Dangerous pattern check
└► returns boolean → allowed?

[Result]
└► True = content is safe
└► False = content rejected

  1. Content Security Policy (CSP)

// src-tauri/src/security.rs
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime, Manager, AppHandle
};

🔐 1. init_security Plugin — High-Level Purpose
`pub fn init_security() -> TauriPlugin {
Builder::new("security")
.setup(|app| {
app.listen_global("tauri://created", |event| {
// set window title

  • }); Ok(()) }) .build() }` What it does: Defines a Tauri plugin named "security". Why it’s needed: Plugins modularize cross-cutting functionality (security, protocols, etc.) github.com+15tauri.app+15tauri.app+15. .setup(...) hook: Called during app initialization—here we add a global listener for "tauri://created". ⚙️ 2. app.listen_global("tauri://created", ...) Event purpose: Fired when any window is created. Listener callback: Allows modifying window properties — like setting the title. Under the hood:

Tauri registers this callback before GUI windows appear. When window creation occurs, Tauri invokes this function globally
Thread-safety nuance: listen_global isn't thread-safe across contexts—fine for setup usage tauritutorials.com+5github.com+5tauri.app+5.
⚙️ 2. app.listen_global("tauri://created", ...)
Event purpose: Fired when any window is created.
Listener callback: Allows modifying window properties — like setting the title.
Under the hood:

Tauri registers this callback before GUI windows appear. When window creation occurs, Tauri invokes this function globally

Thread-safety nuance: listen_global isn't thread-safe across contexts—fine for setup usage
🧩 3. window.set_title(...)
Purpose: Provides secure control over the window title from Rust.
Why it’s relevant: Using the "tauri://created" global event ensures centralized title enforcement, avoiding race conditions and messy frontend overrides.
🛡️ 4. validate_content_security

[tauri::command]

:contentReference[oaicite:15]{index=15}
:contentReference[oaicite:16]{index=16}
:contentReference[oaicite:17]{index=17}
:contentReference[oaicite:18]{index=18}
:contentReference[oaicite:19]{index=19}
}
}
Ok(true)
}
What it does: A Tauri command to validate JavaScript content against suspicious CSP patterns.
Why it’s needed: To avoid injecting unsafe inline scripts or dangerous schemes that could bypass CSP.
Best practices:

Run this check in Rust, not frontend, to avoid tampering.
Ensure patterns are comprehensive (e.g., consider , event-hander attributes, etc.), and keep updated alongside your CSP config
🔍 5. Under-the-Hood: How It All Works

🧠 First-Principles Insight
Modularity: Packaging security logic into a plugin separates concerns and avoids polluting app setup.
Event-centric design: Reacting to tauri://created allows central control of windows—titlebars, CSP, etc.—aligning with modern secure app design.
Command-level CSP checks: Instead of trusting frontend input, run validation in Rust to maintain trust boundaries.
Declarative defense: Your code acts as a security guard — enforcing rules both at the boundary (input sanitization) and infrastructure (CSP injections).
Some Enhancement Recommendation (AI Suggestion) :

Unlisten on shutdown: Although global, consider plugin cleanup for long-lived apps.
Whitelist rules: Instead of rejecting on any pattern, whitelist allowed content for better security postures.
Access control: Export commands under security prefix—invoke('security|validate_content_security').

  1. File System Scoping { "tauri": { "allowlist": { "fs": { "scope": [ "$APPDATA/myapp/", "$HOME/Documents/MyApp/", "$RESOURCE/templates/**" ] } } } } File System Operations
  2. Advanced File Operations

// src-tauri/src/file_operations.rs
use tauri::{command, AppHandle, Manager};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};

  1. Frontend File Management // src/lib/file-manager.ts import { open, save } from '@tauri-apps/api/dialog'; import { readTextFile, writeTextFile, createDir } from '@tauri-apps/api/fs'; import { TauriAPI } from './tauri-api'; Imports essential Tauri APIs for

File dialogs: Opening and saving files via native OS dialogs (open, save).
File system operations: Reading, writing files, and creating directories (readTextFile, writeTextFile, createDir).
Includes a custom wrapper module (TauriAPI) to:

Simplify and centralize Tauri-specific functionality.
Provide a cleaner, more maintainable interface for frontend file operations.
Sets the foundation for seamless file management (open/save, read/write, directory creation) in a desktop app built with Tauri.

  1. State Management What’s Going On Here? When you want to build a desktop app using Tauri, and you want to keep user preferences, cache, and session data in memory so they can be easily read/written from the frontend.

Think of it like:

🧠 AppState: a brain that stores current preferences, recent files, and some in-memory data.
🗝️ Tauri commands: remote controls (JavaScript frontend) calling Rust functions.
🧵 Mutex and Arc: ways to safely share and lock data when multiple parts of the app (threads) want to look inside the brain.
🏗️ 1. Struct Breakdown
#[derive(Debug)]
pub struct AppState {
pub preferences: Arc<Mutex<UserPreferences>>,
pub cache: Arc<Mutex<HashMap<String, String>>>,
pub session_data: Arc<Mutex<HashMap<String, serde_json::Value>>>,
}

What’s happening?
AppState is a struct — a "box" to group multiple things together.
It stores:

preferences: your app's settings (UserPreferences)
cache: quick in-memory key-value store (like a tiny dictionary)
session_data: session-related data, like cookies or current user info
Why Arc>?
Arc: Atomically Reference Counted — allows multiple parts of the program to share the same data.
Mutex: Mutual Exclusion — lets only one thread at a time read or write data. Like a bathroom lock 🚪.
TL;DR: We’re sharing the same data between multiple parts of the app, but locking it so only one person writes at a time.

🏗️ 2. Default Implementation
impl Default for AppState {
fn default() -> Self {
Self {
preferences: Arc::new(Mutex::new(UserPreferences::default())),
cache: Arc::new(Mutex::new(HashMap::new())),
session_data: Arc::new(Mutex::new(HashMap::new())),
}
}
}

What’s happening?
If someone says “Give me a blank AppState,” this is how we make one:

Fresh preferences (UserPreferences::default())
Empty cache and session data (empty hash maps)
Think of this as a fresh user logging into the app for the first time.

🔌 3. Tauri Commands (frontend can call these)
These functions are callable from your JavaScript frontend through Tauri. They all use state: State<'_, AppState> to access the shared app state.

📜 get_preferences
#[tauri::command]
pub async fn get_preferences(state: State<'_, AppState>) -> Result<UserPreferences, String> {
let prefs = state.preferences.lock()
.map_err(|e| format!("Failed to lock preferences: {}", e))?;
Ok(prefs.clone())
}

Lock the preferences (like turning the bathroom key).
Clone the UserPreferences to return it (so we don’t give the actual inner one).
If the lock fails (some thread panicked), we return an error.
🛠️ update_preferences
#[tauri::command]
pub async fn update_preferences(state: State<'_, AppState>, new_prefs: UserPreferences) -> Result<(), String> {
let mut prefs = state.preferences.lock()
.map_err(|e| format!("Failed to lock preferences: {}", e))?;
*prefs = new_prefs;
Ok(())
}

Lock preferences.
Overwrite the old preferences with the new ones.
Return success.
📁 add_recent_file
`#[tauri::command]
pub async fn add_recent_file(state: State<'_, AppState>, file_path: String) -> Result<(), String> {
let mut prefs = state.preferences.lock()
.map_err(|e| format!("Failed to lock preferences: {}", e))?;

prefs.recent_files.retain(|f| f != &file_path);
prefs.recent_files.insert(0, file_path);
prefs.recent_files.truncate(10);
Ok(())
Enter fullscreen mode Exit fullscreen mode

}
Breakdown:
Get lock on preferences.
Remove file if already in recent list (to avoid duplicates).
Add it to the front (most recent at top).
Only keep 10 items in the list (like a “Most Recently Used” list).
🧠 cache_set
#[tauri::command]
pub async fn cache_set(state: State<', AppState>, key: String, value: String) -> Result<(), String> {
let mut cache = state.cache.lock()
.map_err(|e| format!("Failed to lock cache: {}", e))?;
cache.insert(key, value);
Ok(())
}
Lock the cache.
Insert a new key-value.
Simple dictionary update.
🔍 cache_get
#[tauri::command]
pub async fn cache_get(state: State<'
, AppState>, key: String) -> Result, String> {
let cache = state.cache.lock()
.map_err(|e| format!("Failed to lock cache: {}", e))?;
Ok(cache.get(&key).cloned())
}`
Lock the cache.
Get value if it exists.
Return a clone of it (not a reference).
🧩 Putting It All Together

🔁 Bonus: Safety + Performance
Arc> is not the most performant, but it’s easy and safe for Tauri apps.
If needed, you can later use RwLock (read-write locks) or even actors for better performance.

  1. Frontend State Management // src/lib/state-manager.ts import { invoke } from '@tauri-apps/api/tauri'; 🔧 1. The UserPreferences Interface export interface UserPreferences { theme: string; language: string; autoSave: boolean; recentFiles: string[]; } 💡 What is it? An interface defines the shape of an object.

🧠 Why does it exist?
To describe what a user’s preferences look like. It’s like a contract — if a variable claims to be UserPreferences, it must have:

theme → text like "dark" or "light"
language → text like "en" or "bn"
autoSave → true or false
recentFiles → list of filenames
🏛️ 2. The Singleton StateManager Class
export class StateManager {
private static _instance: StateManager;
💡 What is this?
A singleton. There should be only one instance of StateManager in your whole app.

private _preferences: UserPreferences | null = null;
private _cache: Map = new Map();
_preferences: stores the latest loaded preferences.
_cache: a key-value memory store, like a tiny in-memory database.
🧠 Why?
Instead of fetching preferences from Tauri every time, it keeps a local copy and cache, making the app faster.

🚪 3. Accessing the Singleton
static getInstance(): StateManager {
if (!StateManager._instance) {
StateManager._instance = new StateManager();
}
return StateManager._instance;
}
💡 What’s happening?
This lazy-creates the singleton. If it’s not made yet, make it. Otherwise, reuse it.

📦 4. Loading Preferences (Lazy Fetch)
async loadPreferences(): Promise {
if (!this._preferences) {
this._preferences = await invoke('get_preferences');
}
return this._preferences;
}
invoke calls the Rust backend command get_preferences.
Only fetches once, then reuses.
🧼 5. Updating Preferences
async updatePreferences(prefs: UserPreferences): Promise {
await invoke('update_preferences', { newPrefs: prefs });
this._preferences = prefs;
}
This updates both:

Backend Rust state (AppState)
Local cached copy (this._preferences)
🗂️ 6. Managing Recent Files
async addRecentFile(filePath: string): Promise {
await invoke('add_recent_file', { filePath });
if (this._preferences) {
this._preferences.recentFiles = [
filePath,
...this._preferences.recentFiles.filter(f => f !== filePath)
].slice(0, 10);
}
}
🔁 What’s going on?
Sends update to backend.
Locally adjusts recent files:
Removes duplicates
Adds newest to top
Keeps only the last 10
🧠 7. Cache Set/Get (with Local Mirror
async cacheSet(key: string, value: any): Promise {
const serialized = JSON.stringify(value);
await invoke('cache_set', { key, value: serialized });
this._cache.set(key, value);
}
value is converted to string because the Rust backend expects a String.
Then saved both in backend and local memory.
async cacheGet(key: string): Promise {
if (this._cache.has(key)) {
return this._cache.get(key);
}
const cached = await invoke('cache_get', { key });
if (cached) {
const parsed = JSON.parse(cached);
this._cache.set(key, parsed);
return parsed;
}
return null;
}
First tries to get from memory.
If not found, asks backend and updates local memory.
Deserializes JSON string from backend into JS object.
🧼 8. Cache Clear
clearCache(): void {
this._cache.clear();
}
Local memory only. Doesn’t affect backend.

🧠 9. React Context API Integration
Now we’re building the React global state system.

interface StateContextType {
preferences: UserPreferences | null;
updatePreferences: (prefs: UserPreferences) => Promise;
addRecentFile: (path: string) => Promise;
cacheGet: (key: string) => Promise;
cacheSet: (key: string, value: any) => Promise;
}
🧠 Why?
To inject global access to state across components using React’s context.

🌐 10. Provider Component
export const StateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [preferences, setPreferences] = useState(null);
const stateManager = StateManager.getInstance();
💡 What’s happening?
Initializes state
Grabs singleton
On mount (useEffect), loads preferences:
useEffect(() => {
stateManager.loadPreferences().then(setPreferences);
}, []);
🔄 11. Update + Recent Files
const updatePreferences = async (prefs: UserPreferences) => {
await stateManager.updatePreferences(prefs);
setPreferences(prefs);
};
const addRecentFile = async (path: string) => {
await stateManager.addRecentFile(path);
if (preferences) {
setPreferences({
...preferences,
recentFiles: [path, ...preferences.recentFiles.filter(f => f !== path)].slice(0, 10)
});
}
};

Reconciles local UI state with backend state.

🌐 12. Providing Context
<StateContext.Provider value={{
preferences,
updatePreferences,
addRecentFile,
cacheGet: stateManager.cacheGet.bind(stateManager),
cacheSet: stateManager.cacheSet.bind(stateManager)
}}>
{children}
</StateContext.Provider>

Passes down the actions and current state via context to the whole app.

🧠 13. Hook for Consumers
export const useAppState = () => {
const context = useContext(StateContext);
if (!context) {
throw new Error('useAppState must be used within StateProvider');
}
return context;
};

💡 What’s this?
A simple hook for any component to access the global state safely.

Reference :

Handling File Dialogs
File System Plugin
Tauri 2.0 | Tauri
Stay tuned for next episode for the rest. In between subscribe us in medium and subsctack and follow us on youtube

Tauri #DesktopApps #FileManagement #FileSystem #OpenSource #JavaScript #NativeDialogs #CrossPlatform

Top comments (1)

Collapse
 
wpessers profile image
wpessers

I'm excited to start testing out tauri, and I believe your post really conveys a lot of good information in the right way. However, I recommend formatting your code differently here. If you enclose the code between three backticks instead of 1, you'll get code blocks. Use the preview feature to see how it looks afterward. You can even specify the programming language right after your first 3 backticks so that the code is highlighted properly!