Best Practices for the Dialog API

Discover best practices for using the Tauri v2 Dialog API to build reliable, user-friendly file and message dialogs with robust validation and error handling.

The Dialog API gives your app native file open, save, and message dialogs that feel like part of the operating system. But a single unexpected null return, a blocked window, or a missing permission can turn a simple prompt into a broken experience. This guide walks through the patterns that keep dialogs predictable, fast, and safe across platforms.

User Experience Best Practices

Dialogs interrupt the user. You control how smooth or jarring that interruption feels. The rules are simple: always tell the user what’s happening, never freeze the app, and expect that they might cancel at any moment.

Choosing the Right Dialog Type

The plugin offers three families of dialogs: message, ask/confirm, and file open/save. Picking the wrong one confuses users and adds unnecessary code.

  • Message dialogs (message) show information with a single “OK” button. Use them when the user only needs to acknowledge something — a file not found, an operation complete.
  • Ask/confirm dialogs (ask, confirm) return a boolean. Use confirm for Ok/Cancel choices (like “Save changes?”) and ask for Yes/No decisions. Both serve decisions; pick based on button labels.
  • File dialogs (open, save) let the user pick or create files. They return a path or null. Don’t use them as a makeshift settings selector — only for actual file system interactions.

If you need a folder selection, use open with directory: true as described in Folder Dialogs.

Writing Clear Prompts and Titles

The dialog’s message text and title are the first things a user reads. A vague message causes hesitation; a clear one gets a quick answer.

  • Titles should identify your app or the context: “Save Project” instead of “Dialog”.
  • Message text should state the action and consequence: “This will delete all selected items. Continue?” rather than “Are you sure?”.

Both ask and confirm accept a kind option (info, warning, error) that sets the system icon. Use the icon that matches the stakes — warning for destructive actions, error for failures, info for neutral prompts.

Keeping the UI Responsive

A dialog that freezes the window behind it feels like the app has crashed. This happens most often when Rust commands call blocking dialog methods from the main thread. While the dialog waits for user input, the event loop stops, and the whole window becomes unresponsive.

Blocking Dialogs Can Freeze the Window:

Calling blocking_show() or blocking_pick_file() inside a #[tauri::command] invoked from the frontend locks the main thread until the dialog closes. On some platforms, especially macOS, this makes the parent window completely unresponsive. Prefer the non-blocking, callback-based API (show(|result| ...), pick_file(|path| ...)) whenever you call dialogs from Rust commands that the frontend invokes.

In JavaScript, the @tauri-apps/plugin-dialog functions already return promises, so await keeps the event loop free. That’s the ideal path for most UI-driven dialogs.

This example shows a command that uses the non‑blocking file picker. The frontend calls it, and the dialog opens without freezing the window.

src-tauri/src/lib.rs
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
#[tauri::command]
fn pick_file_non_blocking(app: AppHandle) {
    app.dialog()
        .file()
        .pick_file(|file_path| {
            match file_path {
                Some(path) => {
                    // path.to_string() gives the file path
                    println!("Selected: {}", path.to_string());
                }
                None => {
                    println!("User cancelled the dialog.");
                }
            }
        });
}

The same logic on the frontend side stays clean with async/await:

src/App.tsx
import { open } from '@tauri-apps/plugin-dialog';
async function selectFile() {
    // The UI remains responsive while the dialog is open.
    const file = await open({
        multiple: false,
        directory: false,
        filters: [{
            name: 'Documents',
            extensions: ['txt', 'md', 'pdf'],
        }],
    });
    if (file) {
        console.log('Chosen file:', file);
    } else {
        console.log('Dialog cancelled.');
    }
}

You see the pattern: the function returns null (or None) when the user cancels. That’s not an error — it’s a deliberate action you must handle.

Providing Sensible Defaults and Filters

File dialogs become far more usable when they start in a logical folder and restrict shown files to the types your app actually handles.

  • Use filters to list allowed extensions. A text editor might use ['txt', 'md', 'rs']. Without filters, users see every file on the system and can pick unusable formats.
  • Set defaultPath (available in both JavaScript and Rust builders) to an appropriate starting directory, such as the user’s documents folder or the last opened folder.

When using filters, include a name that describes the file group — "Text Documents" for .txt and .md. This label appears in the dropdown on Windows and Linux.

Handling Cancellation Gracefully

Every dialog that returns a value can be dismissed. The open function returns null; ask returns false; save returns null. Your code must treat cancellation as a normal case, not a failure.

Don’t show an error message when the user decides not to pick a file. Instead, silently abort the action that needed the file. Only complain if the user explicitly intended to complete an action and the dialog produced an unusable result — that’s a different scenario best caught by validation.

Cancellation is Not an Error:

A null path or false boolean means the user chose not to proceed. Treat it as a no‑op. Only raise an error if the dialog returned a value that fails validation (e.g., a file with the wrong extension despite your filter).

Cross-Platform Considerations

Dialogs behave subtly differently on each operating system, and some features are outright missing on mobile.

  • Folder picker (open({ directory: true })) does not work on Android or iOS. If your app targets mobile, provide an alternative selection method or disable that feature.
  • macOS quirks: Dialogs may appear in odd screen positions if no parent window is provided. Passing a window handle to the Rust builder (parent_window(window)) anchors the dialog correctly.
  • Filter support: Windows and Linux show filter dropdowns; macOS uses a slightly different UI for file type filtering but still honors the extensions list.

Test your dialogs on every target platform early. A dialog that works perfectly on your development machine might look broken elsewhere.

Validation of Dialog Results

A file picker returning a path doesn’t guarantee the file is valid, accessible, or even still there. Always validate what the dialog hands back before reading or writing.

Checking Return Values for Cancellation

As noted, the first validation is checking for null or false. That’s the cancellation guard. Without it, any subsequent .unwrap() or property access will throw an error that the user didn’t trigger — a broken dialog experience.

Validating the Chosen File

Even after a user picks a file, several things can go wrong:

  • The file might have been deleted between selection and access.
  • The file extension might not match what your app expects, especially if the user typed a different extension in the save dialog.
  • The file might be too large for your processing pipeline or have zero bytes.
  • Permissions might prevent reading (though the dialog may still have shown it).

For reading files, check that the path exists, the extension is allowed, and the size is reasonable. For saving, confirm the path’s parent directory exists and is writable.

Here’s a JavaScript helper that validates a file path before use:

import { stat } from '@tauri-apps/plugin-fs'; // using fs plugin
async function validateChosenFile(path: string, allowedExtensions: string[], maxSizeBytes: number): Promise<boolean> {
    try {
        const metadata = await stat(path);
        if (!metadata.isFile) {
            console.error('Chosen path is not a file.');
            return false;
        }
        if (metadata.size > maxSizeBytes) {
            console.error('File exceeds maximum allowed size.');
            return false;
        }
        const ext = path.split('.').pop()?.toLowerCase();
        if (!ext || !allowedExtensions.includes(ext)) {
            console.error('File extension not allowed.');
            return false;
        }
        return true;
    } catch {
        // stat failed – file likely doesn't exist or is inaccessible.
        return false;
    }
}

You might wonder why validate extensions if you already set filters in the open call. Filters restrict what the user sees by default, but they can type any filename in the dialog’s text field (on most platforms) and bypass the filter. Never trust the filter alone for security; always re‑check the extension and type before operating on the file.

Never Trust Filters for Security:

File dialog filters are a UI convenience, not a security boundary. A determined user can override them. Always validate the actual file extension, MIME type (if available), and content before processing or executing.

Sanitizing Paths with the Path API

Raw paths from dialogs may contain unexpected characters or be in platform‑specific formats. Use the Path API (@tauri-apps/api/path) to convert them to a consistent form before passing them to other native APIs or to Rust commands. This avoids bugs where \ vs / or drive letters cause failures.

import { convertFileSrc } from '@tauri-apps/api/core';
import { resolveResource } from '@tauri-apps/api/path';
async function processSelectedFile(dialogPath: string) {
    // Normalize and verify the path is within an allowed directory tree.
    const resolved = await resolveResource(dialogPath);
    // Use the resolved path for further native operations.
}

Confirming Before Overwriting in Save Dialogs

The save dialog doesn’t automatically warn about overwriting existing files. It returns the chosen path, and you are responsible for checking existence. Use the file system API to test whether the file exists. If it does, show a separate confirm dialog before writing.

import { save, confirm } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
async function saveFileWithOverwriteCheck() {
    const path = await save({
        filters: [{ name: 'Images', extensions: ['png', 'jpg'] }],
    });
    if (!path) return; // user cancelled
    if (await exists(path)) {
        const overwrite = await confirm('A file with this name already exists. Overwrite?', {
            title: 'Overwrite File',
            kind: 'warning',
        });
        if (!overwrite) return;
    }
    // proceed to write to path...
}

This two‑dialog flow — pick a path, then confirm overwrite — mirrors standard desktop app behavior.

Error Handling for Dialogs

A dialog can fail for reasons beyond cancellation: missing permissions, unsupported options, or internal platform errors. Robust error handling catches these and gives the user a clear, actionable message.

Handling Cancellation vs. Errors

Cancellation is not an error, but a failed dialog invocation is. The JavaScript open, save, and message functions reject their promises if the plugin cannot create the dialog. The Rust callback‑based methods may never fire the callback in some failure scenarios, so always wrap Rust dialog calls in error‑handling contexts.

In JavaScript, use a try/catch:

try {
    const file = await open({ multiple: false });
    if (file === null) {
        // user cancelled — nothing to do
        return;
    }
    // valid file selected
} catch (err) {
    console.error('Dialog failed to open:', err);
    // Show a user-friendly message, not the raw error string.
}

In Rust, when using non‑blocking callbacks, you can handle the case where the dialog itself might not open due to permission issues by checking plugin availability or wrapping in a command that returns a Result.

Common Plugin Errors and Their Causes

  • Permission denied: The dialog command is not allowed in your capability file. The error will appear in the console. Double‑check that you’ve granted dialog:allow-open, dialog:allow-save, or dialog:allow-message as needed.
  • Invalid options: Passing an unsupported combination (e.g., multiple: true with directory: true on platforms where that’s not allowed) may cause the dialog to not appear or throw.
  • Parent window not found: In Rust, if you reference a window that doesn’t exist, the dialog may fail silently. Always pass a valid Window handle.

Missing Permissions Cause Silent Failures:

If you don’t include the required dialog permission in your app’s capability, the dialog function will throw an error or reject the promise. On some platforms, it may fail without a visible pop‑up. Always grant the specific dialog scopes your app needs.

Displaying User-Friendly Error Messages

Never dump a raw Rust error or stack trace into a message dialog. Translate errors into short, actionable sentences.

For example, if the dialog fails because of a missing permission, show: "The app needs permission to open file dialogs. Please check the app’s security settings."

A Good Error Message Example:

A well‑crafted error message tells the user what went wrong and what they can do. Compare: "Error 0x80004005: Unspecified error" vs "Could not open file picker. Make sure the app has permission to access files." The second builds trust.

Logging for Debugging

While users see a friendly message, developers need the raw error. Use the logging plugin (@tauri-apps/plugin-log or log crate in Rust) to record dialog errors with full details. This gives you the information needed to fix platform‑specific issues without frightening users.

Common Mistakes and How to Avoid Them

Even experienced developers trip over a few patterns. Spotting these early saves hours of debugging.

Forgetting to Handle `null` After a File Dialog:

The most frequent bug is calling .unwrap() or assuming a non‑null path after open. When the user cancels, you get null — and the app throws an uncaught error. Always branch on if (file === null) or if let Some(path) = ....

Using Blocking Dialogs Inside Commands:

As described earlier, blocking_* methods lock the main thread. This causes a complete UI freeze on macOS and sometimes on Windows. Use the callback‑based API from Rust commands that the frontend invokes, or handle dialogs entirely from the JavaScript side where async/await keeps the event loop alive.

Applying Folder Picker on Mobile Without a Fallback:

The folder picker is unsupported on Android and iOS. If your app uses open({ directory: true }), it will silently fail on mobile. Guard platform‑specific code with a capability check or wrap it in a try/catch and show an alternative input method (e.g., a text field for a path).

Neglecting to Validate File Content After Selection:

Filters are UI hints, not security features. Always re‑verify file extensions, size, and if possible, the actual file signature (magic bytes) before reading or executing. This is especially important when the chosen file path comes from a save dialog or is user‑typed.

Summary

Building reliable dialogs in Tauri v2 comes down to three principles: never freeze the UI, always treat cancellation as normal, and validate everything that comes back.

  • Use non‑blocking APIs in Rust and async/await in JavaScript to keep the window responsive.
  • Design clear, context‑rich prompt messages that match the dialog kind (message, confirm, ask, file dialogs).
  • Check for null and false after every dialog — cancellation is part of the flow.
  • Validate file paths, extensions, and existence before acting on them. Filters are not security.
  • Catch plugin errors and display user‑friendly messages while logging the raw details for debugging.
  • Test on all target platforms; folder dialogs and window anchoring behave differently on macOS and mobile.

These habits keep your dialog‑driven features feeling native and trustworthy, no matter which operating system your users run.