Tauri File Access: Build a Notes App with React & Rust
Video: Tauri File Access: Build a Notes App with React & Rust by Taught by Celeste AI - AI Coding Coach
A complete Tauri notes app: open existing files, edit in a textarea, save back, save-as new file. Three plugin calls, one React component, real native file I/O.
In the previous lesson we covered the file system and dialog plugins individually. Today we put them together into a working notes app. Open a .txt or .md file, edit it, save it back. The same shape every text editor takes.
What we are building
A single-pane notes editor:
- Toolbar with New / Open / Save / Save As buttons.
- Big multi-line textarea filling the rest of the window.
- Status bar at the bottom showing the current file path or "Untitled."
Three React state pieces drive everything: text (the buffer contents), currentPath (the file path or null), dirty (has the buffer been edited since last save).
State
const [text, setText] = useState("");
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
text mirrors the textarea. currentPath is null for untitled buffers, a string after opening or saving. dirty flips to true on edits, back to false after a save.
New file
async function newFile() {
if (dirty && !(await confirm("Discard unsaved changes?"))) return;
setText("");
setCurrentPath(null);
setDirty(false);
}
Reset everything to the initial state, but prompt first if there are unsaved edits. The confirm dialog returns a boolean.
Open file
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
async function openFile() {
if (dirty && !(await confirm("Discard unsaved changes?"))) return;
const selected = await open({
multiple: false,
filters: [{ name: "Text", extensions: ["txt", "md", "rs", "py"] }],
});
if (typeof selected !== "string") return;
const content = await readTextFile(selected);
setText(content);
setCurrentPath(selected);
setDirty(false);
}
open() shows the OS file picker. Returns null on cancel, otherwise a path string. Read the file's contents, populate text, store the path.
Save (or Save As if no path yet)
async function saveFile() {
let path = currentPath;
if (!path) {
path = await save({
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (typeof path !== "string") return;
}
await writeTextFile(path, text);
setCurrentPath(path);
setDirty(false);
}
If we already have a path, just write to it. Otherwise show a Save dialog and use the chosen path. Either way, write the buffer contents and clear the dirty flag.
Save As
async function saveFileAs() {
const path = await save({
defaultPath: currentPath ?? "untitled.txt",
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (typeof path !== "string") return;
await writeTextFile(path, text);
setCurrentPath(path);
setDirty(false);
}
Always show the Save dialog. Default to the current filename if any. After save, update currentPath so subsequent Saves go to the new file.
The textarea
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
if (!dirty) setDirty(true);
}}
className="w-full h-full font-mono p-4 outline-none"
/>
Standard React controlled textarea. Every keystroke updates text and marks the buffer dirty.
Status bar
<footer className="px-4 py-2 text-sm border-t bg-slate-100">
{currentPath ?? "Untitled"} {dirty && "(modified)"}
</footer>
Shows the current file or "Untitled," plus a "(modified)" indicator while dirty.
Toolbar
<header className="flex gap-2 p-2 border-b bg-slate-50">
<button onClick={newFile}>New</button>
<button onClick={openFile}>Open</button>
<button onClick={saveFile}>Save</button>
<button onClick={saveFileAs}>Save As</button>
</header>
Four buttons, each wired to the corresponding async function.
Putting it together
function App() {
const [text, setText] = useState("");
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// ... newFile, openFile, saveFile, saveFileAs
return (
<div className="h-screen flex flex-col">
<header>...</header>
<textarea ... className="flex-1" />
<footer>...</footer>
</div>
);
}
About 80 lines for a working notes app. The rest is styling and polish.
Capabilities
For all of this to work, the capability file needs the right permissions:
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-confirm"
]
}
Without these, the relevant calls fail with ACL errors at runtime.
Polishing
A few small features that make the difference between "demo" and "I would actually use this":
Cmd+S to save.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
saveFile();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [text, currentPath, dirty]);
Auto-save with a debounce. Add a useEffect that calls saveFile ~2 seconds after the last edit. Pair with a "saved" indicator in the status bar.
Drag and drop. Listen for the tauri://drag-drop event to handle files dropped on the window.
Window title reflects current file.
useEffect(() => {
document.title = currentPath ? `${basename(currentPath)} — Notes` : "Untitled — Notes";
}, [currentPath]);
Multi-tab. Replace the single buffer with Vec<Buffer> and add a tab strip. Same logic applied per tab.
Each addition is small. Together they make the app feel real.
Common mistakes
Forgetting to clear dirty after save. Saved files still show "modified" because the flag was never cleared.
Reading binary files as text. .png or .exe will return garbled bytes. Either filter the open dialog to text-only extensions or detect binary content and refuse.
Not handling the cancel case. open() and save() return null when the user cancels. Always check the type before using the path.
Saving outside permitted scope. If your capability file scopes fs operations to $DOCUMENT/*, saving to /etc/foo.txt fails. Either widen the scope or accept the limitation.
What's next
Next lesson: window customization. Frameless windows, custom titlebars, transparent backgrounds — what to do when the OS chrome doesn't match your design.
Recap
A notes app is text, currentPath, dirty plus four functions: New, Open, Save, Save As. Plugins handle the I/O. Capabilities grant permission. Polish with keyboard shortcuts, drag-and-drop, auto-save, multi-tab.
Next: window customization.