Skip to content
Select theme

Editing notes and autosave

Step 7 of 13. Step 6 closed the read loop: notes go in through create_note and come back through list_notes. But a typo is permanent — the only way to fix a note is to create another one. This step makes the list editable. Click a note, and it opens in the editor; keep typing, and it saves itself half a second after you stop. The debounce lives in the front end, the write lives in a new update_note command. The step starts from tauri-ch05, the tag step 6 left behind.

Nothing to install. UPDATE is ordinary SQL through the rusqlite connection step 5 set up, and the debounce is about ten lines of TypeScript against setTimeout. src-tauri/Cargo.toml keeps its step-6 content:

src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.37", features = ["bundled"] }

update_note sits between the two commands step 5 and 6 added — same State<'_, Mutex<Connection>>, same map_err(|e| e.to_string()) on every fallible step, and the same rule both steps established: an async command with a borrowed parameter must return Result.

use std::sync::Mutex;
use rusqlite::Connection;
use tauri::State;
#[tauri::command]
async fn update_note(
state: State<'_, Mutex<Connection>>,
id: i64,
title: String,
body: String,
) -> Result<(), String> {
let conn = state.lock().map_err(|e| e.to_string())?;
let updated = conn
.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
rusqlite::params![title, body, id],
)
.map_err(|e| e.to_string())?;
if updated == 0 {
return Err(format!("no note #{id}"));
}
Ok(())
}

(The use lines are what the block needs to compile on its own; in src-tauri/src/lib.rs each appears once, at the top.)

The part worth slowing down for is updated. Connection::execute returns the number of rows the statement changed, and an UPDATE ... WHERE id = ? that matches nothing changes zero rows — no error, no panic, just silence. If the front end asks for note #999 and the command returns Ok(()), the front end will print “saved note #999” over a write that never happened. So zero rows becomes an error, and the front end’s catch shows Error: no note #999 instead of a lie.

You can watch the row count yourself without the app:

use rusqlite::Connection;
fn main() -> Result<(), rusqlite::Error> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT ''
);
INSERT INTO notes (id, title) VALUES (1, 'Shopping list'), (2, 'Article ideas');",
)?;
let updated = conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
rusqlite::params!["Shopping list (edited)", "milk, eggs", 1],
)?;
println!("update #1 changed {updated} row");
let updated = conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
rusqlite::params!["ghost", 99],
)?;
println!("update #99 changed {updated} rows");
Ok(())
}
update #1 changed 1 row
update #99 changed 0 rows

Register the command with the others in src-tauri/src/lib.rs:

.invoke_handler(tauri::generate_handler![
add, divide, stats, create_note, list_notes, update_note
])

Autosave naive version: one invoke("update_note", …) per keystroke. Typing “Shopping” fires eight IPC round trips and eight UPDATEs for one word. The standard fix is a debounce: every keystroke resets a timer, and only when the keystrokes stop does the save fire.

In src/App.tsx, three new pieces of state — the body text, which note is open, and the timer:

const [body, setBody] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [saved, setSaved] = useState("");
const [notes, setNotes] = useState<Note[]>([]);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

saveTimer is a useRef rather than state on purpose: resetting a timer is not a change the UI should re-render for. The type is ReturnType<typeof setTimeout> rather than number because browsers return number but Node’s types return an object — the generic form compiles in both, and Tauri code runs in both.

The debounced save:

function startEdit(note: Note) {
setEditingId(note.id);
setTitle(note.title);
setBody(note.body);
setSaved(`editing note #${note.id}`);
}
function newNote() {
setEditingId(null);
setTitle("");
setBody("");
setSaved("");
}
function scheduleSave(id: number, nextTitle: string, nextBody: string) {
if (saveTimer.current) clearTimeout(saveTimer.current);
setSaved("saving…");
saveTimer.current = setTimeout(async () => {
try {
await invoke("update_note", { id, title: nextTitle, body: nextBody });
setSaved(`saved note #${id}`);
await loadNotes();
} catch (e) {
setSaved(`Error: ${e}`);
}
}, 500);
}

The arguments deserve a sentence. scheduleSave takes the new title and body as parameters instead of reading the title and body state variables inside the timeout. React state is a snapshot: the closure passed to setTimeout would capture the values from the render that scheduled it, and by the time the timer fires 500 ms later, you may have typed three more keystrokes that live in newer renders. The last call to scheduleSave passes the newest values, so that call’s timer saves the newest text. Passing values down beats reaching back into state.

Every call clears the previous timer first, so a burst of keystrokes produces at most one save — half a second after the last one. After it succeeds, loadNotes() refreshes the list, and the title you typed appears in the list on its own.

The title input now schedules a save while a note is open, and a textarea appears underneath it for the body. The button swaps too: with a note open it reads “New note” and clears the editor back to create mode, because “Save note” would create a duplicate.

<div className="row">
<input
value={title}
onChange={(e) => {
const next = e.currentTarget.value;
setTitle(next);
if (editingId !== null) scheduleSave(editingId, next, body);
}}
aria-label="note title"
/>
{editingId === null ? (
<button onClick={saveNote}>Save note</button>
) : (
<button onClick={newNote}>New note</button>
)}
</div>
<textarea
value={body}
rows={6}
cols={40}
disabled={editingId === null}
onChange={(e) => {
const next = e.currentTarget.value;
setBody(next);
if (editingId !== null) scheduleSave(editingId, title, next);
}}
aria-label="note body"
/>```
Clicking a list row opens that note:
```tsx
<li key={note.id} onClick={() => startEdit(note)}>

Two small additions to src/App.css so the row invites clicking and the textarea matches the inputs:

textarea {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
outline: none;
resize: none;
}
li {
cursor: pointer;
}

(The dark-mode block at the bottom of the file gets textarea added to its input, button selector the same way.)

saveNote, loadNotes, and the calculator section of the component are untouched from step 6.

Seed the database sqlite3 can see, as in step 6:

Terminal window
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "DELETE FROM notes; INSERT INTO notes (id, title, body, created_at) VALUES (1, 'Shopping list', '', 1792395356), (2, 'Article ideas', '', 1792400000);"
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT id, title, body FROM notes ORDER BY id;"
1|Shopping list|
2|Article ideas|

Run npm run tauri dev, click “Shopping list”, and edit. The status line reads “saving…” while you type and “saved note #1” half a second after you stop. One way to see the debounce do its job is a temporary probe: an eprintln! at the end of update_note,

eprintln!("UPDATE_NOTE fired: id={id} title={title:?}");

plus a throwaway useEffect that calls scheduleSave five times, 60 ms apart. Typing five keystrokes quickly looks exactly like that to the debounce. The terminal npm run tauri dev runs in shows the command fired once, with the final values:

UPDATE_NOTE fired: id=1 title="Shopping list v5"

and the row on disk agrees:

Terminal window
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT id, title, body FROM notes ORDER BY id;"
1|Shopping list v5|milk, eggs (draft 5)
2|Article ideas|

Five calls in, one write out. Delete the probe afterwards; it is scaffolding, not a feature.

The step is tagged tauri-ch06 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/tauri-desktop-app
git checkout tauri-ch06
npm install
npm run tauri dev

If you have been following along, your tree is current: this step touched src-tauri/src/lib.rs, src/App.tsx, and src/App.css.

Step 8 puts a search box over the notes: SQLite’s FTS5 virtual table, a MATCH query, and highlighted hits. The track overview keeps the full list current as articles land.