Building a Simple CRUD Example
Build a complete Create, Read, Update, Delete application with Tauri v2 and React, using Rust for backend logic and in-memory storage
Working with data is at the heart of most applications. A notes app, a task list, a customer record manager — underneath the surface, all of them perform four fundamental actions: create new entries, read them back, update existing ones, and delete entries that are no longer needed. Those four operations are what developers refer to as CRUD.
This guide walks through building a small CRUD application with Tauri v2 and React. The Rust backend holds an in‑memory list of notes and exposes commands the frontend can call. React provides the user interface: a form to add notes, a list that shows them, and controls to edit or remove each one. By the end you will have a working application that demonstrates the full communication loop between Rust and the browser window.
What this example uses:
All data lives in memory and disappears when the app closes. The goal is to show how commands, state, and the frontend interact — not to build a production database. Once the patterns are clear, you can swap the in‑memory store for a file, SQLite, or any other backend.
Setting Up the Data Store in Rust
Before writing any commands, the backend needs a place to store notes. In Tauri, you can attach shared state to the application with app.manage(). Any command that accepts tauri::State<YourType> will receive a reference to that managed data.
Because multiple commands might try to access the notes at the same time, the state must be safe to use from multiple threads. Rust’s Mutex provides that safety: only one command can read or write the inner data at a time, preventing data races.
Define the data structures inside src-tauri/src/lib.rs (the default location for a Tauri application’s Rust logic):
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Note {
id: u64,
title: String,
content: String,
}
#[derive(Default)]
struct AppData {
notes: Vec<Note>,
next_id: u64,
}
#[derive(Default)]
struct AppState {
data: Mutex<AppData>,
}
AppData holds the list of notes and a counter that produces a unique ID for each new entry. Wrapping it inside Mutex<AppData> inside AppState means every read and write goes through the lock. The #[derive(Default)] attributes give us a clean starting point — an empty vector and an ID counter at zero.
Mutability and concurrency:
Without Mutex, the compiler would not allow the state to be shared across thread boundaries. You will never see a runtime data race because Rust catches the problem at compile time. Still, you must remember to lock the mutex in every command. Forgetting to lock would be a compile error, so the mistake is impossible to ship.
Implementing the Create Operation
Creating a new note means accepting a title and content from the frontend, generating a fresh ID, and pushing the note into the list. The command returns the newly created note so React can display it immediately if needed.
Add the following function to src-tauri/src/lib.rs:
#[tauri::command]
fn create_note(
title: String,
content: String,
state: tauri::State<'_, AppState>,
) -> Result<Note, String> {
let mut data = state.data.lock().map_err(|e| e.to_string())?;
let note = Note {
id: data.next_id,
title,
content,
};
data.next_id += 1;
data.notes.push(note.clone());
Ok(note)
}
Locking the mutex gives exclusive access to data. The ID counter is read, used, and incremented in one go. Because the lock is released at the end of the function, no other command is blocked for longer than necessary.
When the frontend calls this command, the arguments are serialized from JavaScript values. Returning Result<Note, String> tells Tauri to serialize the note back into a JavaScript object on success, or send an error string if the mutex is poisoned (which practically only happens if another thread panicked while holding the lock).
Generating unique IDs:
In a real application you might use UUIDs or database-generated identifiers. The simple counter shown here works well for an in‑memory store because IDs are never reused within a single session.
Implementing the Read Operation
Reading returns all stored notes so the frontend can render them. The command clones the entire list while holding the lock, then releases the lock before the data is serialized and sent over the IPC bridge.
#[tauri::command]
fn read_notes(state: tauri::State<'_, AppState>) -> Result<Vec<Note>, String> {
let data = state.data.lock().map_err(|e| e.to_string())?;
Ok(data.notes.clone())
}
The clone might look wasteful, but it is deliberate. Holding the mutex across an async boundary or while Tauri serializes the response would increase the time other commands are blocked. Returning a cloned vector keeps the critical section as short as possible. For an in‑memory list of a few hundred notes, the performance cost is negligible.
Implementing the Update Operation
Updating a note requires an ID to locate it and optional new values for the title and content. The frontend can send null (which deserializes to None in Rust) for fields it does not want to change.
#[tauri::command]
fn update_note(
id: u64,
title: Option<String>,
content: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<Note, String> {
let mut data = state.data.lock().map_err(|e| e.to_string())?;
let note = data
.notes
.iter_mut()
.find(|n| n.id == id)
.ok_or_else(|| format!("Note with id {} not found", id))?;
if let Some(t) = title {
note.title = t;
}
if let Some(c) = content {
note.content = c;
}
Ok(note.clone())
}
The find method returns a mutable reference to the matching note. The ok_or_else maps a missing note into an error that React can catch. Because the fields are optional, passing null from JavaScript leaves the original value untouched — a design that avoids the need for the frontend to send the entire object just to change one property.
Be careful with partial updates:
If you intend to allow clearing a field (setting it to an empty string, for example), an Option does not distinguish between “no value sent” and “value is an empty string.” For that distinction you would need a more explicit request format. In this example, an empty string for a field will overwrite the existing value.
Implementing the Delete Operation
Deleting a note removes it from the vector by its ID. If the ID does not exist, an error is returned.
#[tauri::command]
fn delete_note(
id: u64,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let mut data = state.data.lock().map_err(|e| e.to_string())?;
let index = data
.notes
.iter()
.position(|n| n.id == id)
.ok_or_else(|| format!("Note with id {} not found", id))?;
data.notes.remove(index);
Ok(())
}
Finding the position with iter().position() avoids a separate search for the note’s existence and its index. Removing by index is efficient because Vec::remove shifts elements once. Returning Ok(()) signals successful deletion without sending data back.
Registering Commands and Structuring the Code
Now that all four commands exist, they need to be registered with the Tauri builder and the state needs to be managed. The standard Tauri v2 template keeps the builder inside a run function in lib.rs and calls it from main.rs.
Update src-tauri/src/lib.rs to include the run function:
pub fn run() {
tauri::Builder::default()
.manage(AppState::default())
.invoke_handler(tauri::generate_handler![
create_note,
read_notes,
update_note,
delete_note,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The manage call places an instance of AppState into Tauri’s managed state so commands can access it. The generate_handler! macro collects all command functions and wires them into the IPC system.
The main.rs file stays minimal:
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app::run();
}
Keeping commands organized:
As the project grows, you can move the command functions into a separate module (for example commands.rs) and re‑export them. The generate_handler! macro works with fully qualified paths, so the registration stays clean.
Building the React Frontend
The frontend lives in the src directory created by the Vite + React template. It uses the @tauri-apps/api package (already included) to invoke Rust commands.
All CRUD interactions happen through the invoke function. The JavaScript objects and arrays returned by Rust are automatically deserialized into their JavaScript equivalents.
Fetching and Displaying Notes
The main App component fetches notes when it first loads and stores them in state:
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
function App() {
const [notes, setNotes] = useState([]);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
const fetchNotes = useCallback(async () => {
try {
const result = await invoke('read_notes');
setNotes(result);
} catch (err) {
console.error('Failed to fetch notes:', err);
}
}, []);
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// Create, update, delete handlers will go here
}
fetchNotes calls the read_notes command and updates the notes array. Wrapping it in useCallback and passing it to useEffect ensures the fetch happens once when the component mounts and avoids unnecessary re‑creations.
Creating a New Note
A form collects the title and content, then sends them to the create_note command. After a successful creation, the note list refreshes:
const handleCreate = async (e) => {
e.preventDefault();
if (!title.trim()) return;
try {
await invoke('create_note', { title, content });
setTitle('');
setContent('');
await fetchNotes();
} catch (err) {
console.error('Failed to create note:', err);
}
};
The title is required for the example; the content can be empty.
Updating an Existing Note
Editing starts when the user clicks an “Edit” button next to a note. The component tracks which note is being edited (editingId) and holds temporary values in editTitle and editContent. Saving sends an update_note call:
const handleUpdate = async (id) => {
try {
await invoke('update_note', {
id,
title: editTitle.trim() || undefined,
content: editContent.trim() || undefined,
});
setEditingId(null);
await fetchNotes();
} catch (err) {
console.error('Failed to update note:', err);
}
};
Passing undefined for an option sends None to Rust, leaving the field unchanged. If the field is an empty string, Rust receives an empty string and updates accordingly — a subtlety to keep in mind.
Deleting a Note
Deleting is the simplest operation: pass the note’s ID to delete_note and refresh the list:
const handleDelete = async (id) => {
try {
await invoke('delete_note', { id });
await fetchNotes();
} catch (err) {
console.error('Failed to delete note:', err);
}
};
The Complete Component
Putting everything together, the App component renders the form and the note list:
return (
<div style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
<h1>Notes</h1>
<form onSubmit={handleCreate} style={{ marginBottom: '2rem' }}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{ display: 'block', marginBottom: '0.5rem', width: '100%' }}
/>
<textarea
placeholder="Content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
style={{ display: 'block', marginBottom: '0.5rem', width: '100%' }}
/>
<button type="submit">Add Note</button>
</form>
<ul style={{ listStyle: 'none', padding: 0 }}>
{notes.map((note) => (
<li
key={note.id}
style={{
border: '1px solid #ccc',
padding: '0.75rem',
marginBottom: '0.5rem',
borderRadius: '4px',
}}
>
{editingId === note.id ? (
<div>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Edit title"
style={{ display: 'block', marginBottom: '0.25rem', width: '100%' }}
/>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="Edit content"
rows={2}
style={{ display: 'block', marginBottom: '0.25rem', width: '100%' }}
/>
<button onClick={() => handleUpdate(note.id)}>Save</button>
<button onClick={() => setEditingId(null)}>Cancel</button>
</div>
) : (
<div>
<strong>{note.title}</strong>
{note.content && <p style={{ margin: '0.25rem 0' }}>{note.content}</p>}
<div>
<button
onClick={() => {
setEditingId(note.id);
setEditTitle(note.title);
setEditContent(note.content);
}}
>
Edit
</button>
<button onClick={() => handleDelete(note.id)}>Delete</button>
</div>
</div>
)}
</li>
))}
</ul>
</div>
);
}
export default App;
Inline styles keep the example self‑contained. In a real project you would replace them with CSS modules or a utility library.
All operations connected:
At this point every Rust command has a corresponding piece of UI. The form creates, the list reads and updates, and the delete button removes. Run the app with npm run tauri dev to see the full flow.
Running and Testing the Application
Start the development server from the project root:
npm run tauri dev
The Vite dev server launches and Tauri opens a native window. You should see the Notes header and a form. Add a few notes, edit their titles, change the content, and delete some entries. Every action goes through the Rust backend and updates the in‑memory store.
Because the state is in memory, restarting the application clears all notes. If you want persistence, you can replace the Vec with code that reads and writes a JSON file or connects to a database. The command signatures stay the same — only the implementation inside the functions changes.
Summary
A CRUD application is the first real test that a frontend‑backend connection works end‑to‑end. In this example, the Rust side owns all the data and exposes pure functions as Tauri commands. React calls those commands with invoke, displays the results, and sends user actions back. The pattern is the same whether you are storing notes in memory, writing files to disk, or querying a full SQL database.