Prevent Double-Click Races in Tauri Apps with Optimistic UI Guards
In a Tauri app, many UI actions call Rust through async IPC: start a process, open a serial port, write a file, import data, stop a worker, or close a resource.
The common mistake is waiting for the Rust command before updating UI state. That leaves a short race window where the user can click the same action twice. In desktop apps, that can mean duplicate background workers, duplicate writes, locked files, or ports that never close cleanly.
The fix is simple: update the local UI state synchronously before awaiting IPC, then roll back only if the command fails.
The race: disabled state changes too late
This pattern is vulnerable:
async function startJob() {
await invoke("start_job");
set({ isRunning: true });
}
If the user double-clicks quickly, both click handlers may enter before isRunning becomes true. Both call start_job.
The Rust side can protect itself too, but the UI should not knowingly send duplicate commands.
Set the guard before await
Set the boolean guard first:
type JobState = {
isRunning: boolean;
error: string | null;
startJob: () => Promise<void>;
stopJob: () => Promise<void>;
};
export const useJobStore = create<JobState>((set) => ({
isRunning: false,
error: null,
startJob: async () => {
set({ isRunning: true, error: null });
try {
await invoke("start_job");
} catch (error) {
set({ isRunning: false, error: String(error) });
}
},
stopJob: async () => {
set({ isRunning: false, error: null });
try {
await invoke("stop_job");
} catch (error) {
set({ error: String(error) });
}
},
}));
Then bind buttons to that state:
export function JobToolbar() {
const { isRunning, startJob, stopJob } = useJobStore();
return (
<>
<button onClick={startJob} disabled={isRunning}>
Start
</button>
<button onClick={stopJob} disabled={!isRunning}>
Stop
</button>
</>
);
}
The second click sees the new disabled state before the first IPC call finishes.
Do not let cleanup depend on the first command succeeding
Stop flows often need two operations:
- Stop the running worker.
- Release the resource.
Do not put both operations in one try block if the second one must happen even when the first one fails:
async function stopAndClose() {
set({ isRunning: false });
try {
await invoke("stop_worker");
} catch (error) {
set({ error: String(error) });
}
try {
await invoke("close_resource");
} catch (error) {
set({ closeError: String(error) });
}
}
This matters for serial ports, file handles, background threads, database connections, and long-running subprocesses. A failed stop command should not automatically skip cleanup.
Validate cheap preconditions before IPC
Some failures do not require a Rust round trip:
async function sendBytes(bytes: number[], intervalMs: number) {
if (bytes.length === 0) {
throw new Error("Command bytes cannot be empty.");
}
if (intervalMs < 10) {
throw new Error("Interval must be at least 10 ms.");
}
await invoke("send_bytes", { bytes, intervalMs });
}
Validate local form state and obvious bounds before calling Rust. The Rust command should still validate input, but the UI can provide faster and clearer feedback for user-correctable errors.
Distinguish recoverable and fatal errors
Not every failure should show the same retry button.
type UiError = {
message: string;
recoverable: boolean;
};
function classifyError(error: unknown): UiError {
const message = String(error);
if (message.includes("NotOpen")) {
return {
message: "The resource is closed. Open it again before retrying.",
recoverable: false,
};
}
if (message.includes("InvalidInput")) {
return {
message: "The input is invalid. Edit it and retry.",
recoverable: true,
};
}
return { message, recoverable: false };
}
This prevents a vague "failed" state from becoming the entire UX. A recoverable error points the user back to the current form. A fatal error points the user to the setup step they must complete first.
Verification checklist
Use this checklist for any Tauri action button that calls IPC:
- The local busy/running flag is set before
await. - Buttons are disabled from local state, not only from Rust response state.
- Rust still enforces idempotency or rejects duplicate starts.
- Cleanup operations run even if stop/cancel fails.
- Cheap validation happens before IPC.
- Error state tells the user whether retry is possible.
References
Summary
For Tauri apps, optimistic UI is not only about feeling fast. It closes real race windows. Set the guard before IPC, keep cleanup independent, and classify errors so the user knows whether to retry, edit input, or reopen the resource.