Skip to content
Select theme

Listing and reading notes

Step 6 of 13. Step 5 left rustnote with a database that only takes writes: create_note inserts a row, sqlite3 can see it, and the app itself cannot. This step closes the loop. A Note struct on the Rust side, a list_notes command that turns the table into a Vec<Note>, and a list in the window that fills itself in when the app opens. The step starts from tauri-ch04, the tag step 5 left behind.

Everything this step needs is already in src-tauri/Cargo.toml: serde and serde_json came with the scaffold in step 2, rusqlite in step 5.

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"] }

The notes table has four columns (step 5’s migration), so a row gets a four-field struct. It goes in src-tauri/src/lib.rs, just above the migration section step 5 added:

use serde::Serialize;
#[derive(Serialize)]
pub struct Note {
id: i64,
title: String,
body: String,
created_at: i64,
}

The Serialize derive is not decoration — it is what lets the struct cross the IPC boundary at all. Leave it off and the build fails at the registration site, not at the struct:

error[E0599]: the method `blocking_kind` exists for reference `&Vec<Note>`, but its trait bounds were not satisfied
--> src/main.rs:6:1
|
6 | #[tauri::command]
| ^^^^^^^^^^^^^^^^^ method cannot be called on `&Vec<Note>` due to unsatisfied trait bounds
...
13 | .invoke_handler(tauri::generate_handler![all_notes]);
| ----------------------------------- in this macro invocation
|
::: /Users/rango/.rustup/toolchains/1.99.0-aarch64-apple-darwin/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:436:1
|
436 | pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
| ------------------------------------------------------------------------------------------------ doesn't satisfy `Vec<Note>: IpcResponse`
|
= note: the following trait bounds were not satisfied:
`Vec<Note>: IpcResponse`
which is required by `&Vec<Note>: tauri::ipc::private::ResponseKind`
= note: this error originates in the macro `__cmd__all_notes` which comes from the expansion of the macro `tauri::generate_handler` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0599`.

blocking_kind is a red herring — the usable information is in the notes: Vec<Note>: IpcResponse is not satisfied. Tauri implements IpcResponse for every Serialize type, so the Vec crosses the boundary only if its element does, and this Note never derived Serialize. The compiler never mentions serde by name; you are expected to make that one jump. Step 4 hit the same wall for Stats, and the fix is the same one line, #[derive(Serialize)] above the struct.

list_notes is create_note’s mirror image: the same State<'_, Mutex<Connection>>, the same map_err(|e| e.to_string()) on every fallible step, and the same rule step 5 established — an async command with a borrowed parameter must return Result.

use std::sync::Mutex;
use rusqlite::Connection;
use serde::Serialize;
use tauri::State;
#[derive(Serialize)]
pub struct Note {
id: i64,
title: String,
body: String,
created_at: i64,
}
#[tauri::command]
async fn list_notes(state: State<'_, Mutex<Connection>>) -> Result<Vec<Note>, String> {
let conn = state.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM notes ORDER BY id DESC")
.map_err(|e| e.to_string())?;
let notes = stmt
.query_map([], |row| {
Ok(Note {
id: row.get(0)?,
title: row.get(1)?,
body: row.get(2)?,
created_at: row.get(3)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<Note>, _>>()
.map_err(|e| e.to_string())?;
Ok(notes)
}

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

Three lines do the work. prepare compiles the SQL once and returns a statement. query_map runs it and feeds each row through the closure, which builds a Note — row.get(n)? reads column n, in the same order the SELECT lists them. Then collect::<Result<Vec<Note>, _>>() walks the iterator and stops at the first row that fails to convert, so one bad row is an error rather than a silent gap.

The query ends in ORDER BY id DESC, newest note first. That is a product decision, not a technical one — ASC returns the same data the other way round.

The command returns Vec<Note>; Tauri serializes it with serde and hands the JSON to the front end. You can watch that happen without the app — the same table, the same struct, in an ordinary binary:

use rusqlite::Connection;
use serde::Serialize;
#[derive(Serialize)]
struct Note {
id: i64,
title: String,
body: String,
created_at: i64,
}
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 '',
created_at INTEGER NOT NULL
);
INSERT INTO notes (title, body, created_at) VALUES
('Shopping list', '', 1792395356),
('Article ideas', '', 1792400000);",
)?;
let mut stmt = conn.prepare("SELECT id, title, body, created_at FROM notes ORDER BY id DESC")?;
let notes = stmt
.query_map([], |row| {
Ok(Note {
id: row.get(0)?,
title: row.get(1)?,
body: row.get(2)?,
created_at: row.get(3)?,
})
})?
.collect::<Result<Vec<Note>, _>>()?;
println!("{}", serde_json::to_string(&notes).unwrap());
Ok(())
}
[{"id":2,"title":"Article ideas","body":"","created_at":1792400000},{"id":1,"title":"Shopping list","body":"","created_at":1792395356}]

Field names travel in two directions, and the two directions do not behave the same. Going into a command, Tauri converts argument names from camelCase to snake_case — step 4’s firstNumber arrived in Rust as first_number. Coming out, nothing is renamed: the JSON keys are the struct’s field names exactly as declared, so created_at stays created_at on the wire and the front end reads note.created_at, not note.createdAt. Forgetting the asymmetry shows up as a list of undefined dates.

You can confirm it on a real IPC round trip. Add a temporary command that echoes back whatever it receives:

#[tauri::command]
fn debug_echo(notes: Vec<serde_json::Value>) -> Result<(), String> {
eprintln!(
"WIRE: {}",
serde_json::to_string(&notes).map_err(|e| e.to_string())?
);
Ok(())
}

Register it, then call it from the front end with a list straight from list_notes:

const rows = await invoke<Note[]>("list_notes");
await invoke("debug_echo", { notes: rows });

The terminal npm run tauri dev runs in prints the bytes the front end was actually holding:

WIRE: [{"body":"","created_at":1792400000,"id":2,"title":"Article ideas"},{"body":"","created_at":1792395356,"id":1,"title":"Shopping list"}]

created_at survives the round trip untouched. The keys are alphabetised here because the echo passes the data through serde_json::Value, which stores them in a sorted map — a struct you serialize directly keeps field order, as the binary above shows. Delete the echo command afterwards; it is scaffolding, not a feature.

list_notes joins the handler list in src-tauri/src/lib.rs:

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

Over in src/App.tsx, the import gains useEffect, and the front end gets its own mirror of the struct:

import { useEffect, useState } from "react";
type Note = { id: number; title: string; body: string; created_at: number };

Note the field names: created_at, snake_case, because that is what the wire carries. The TypeScript type follows the JSON, not JavaScript naming fashion.

The list lives in state, next to the other pieces from step 5:

const [notes, setNotes] = useState<Note[]>([]);

A loader fetches it, and useEffect with an empty dependency array calls the loader once when the component mounts — the list is on screen at launch, before any user input:

async function loadNotes() {
try {
setNotes(await invoke<Note[]>("list_notes"));
} catch (e) {
setSaved(`Error: ${e}`);
}
}
useEffect(() => {
loadNotes();
}, []);

saveNote refreshes the list after a successful save:

async function saveNote() {
try {
const id = await invoke<number>("create_note", { title });
setSaved(`saved note #${id}`);
await loadNotes();
} catch (e) {
setSaved(`Error: ${e}`);
}
}

Rendering is a plain map under the save row:

<ul>
{notes.map((note) => (
<li key={note.id}>
{note.title}{" "}
<small>{new Date(note.created_at * 1000).toLocaleDateString()}</small>
</li>
))}
</ul>

created_at stores seconds since the Unix epoch; Date wants milliseconds, hence the * 1000.

If you would rather not click through the UI first, seed the database sqlite3 can see and step 5 introduced:

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, created_at FROM notes ORDER BY id DESC;"
2|Article ideas|1792400000
1|Shopping list|1792395356

Run npm run tauri dev and the list under the save row shows both, newest first. Type a third title, press Save note, and it lands at the top. Close the window and open it again: the list is still there, because it is loaded from SQLite on mount rather than kept in memory.

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

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

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

Step 7 makes the list editable: a note opens for editing, typing saves itself — debounce on the front end, an update_note command on the back. The track overview keeps the full list current as articles land.