DEV Community

slav
slav

Posted on

QML dialog management is almost always wrong. Here's a better way.

QML dialog management is almost always wrong. Here's a better way.

I've seen this problem in enough Qt projects that it's clearly not an edge case. Everyone handles dialogs differently, and most of those ways range from "inconvenient" to "actively bad."

The usual approaches

Inline declarative. Dialog defined right in the screen component. Works until you need to configure it before showing — then you're setting properties all over the place. And the dialog exists in memory from the moment its parent is created, even if the user never triggers it.

Wrapped in a Component, created dynamically. Better for memory. Worse for everything else — the setup code around createObject() grows fast, and you have to remember to call destroy() everywhere. Stack a few of these in one screen and you've made a mess.

Copy-paste the same dialog in multiple places. The most popular option in deadline-driven codebases.

The actual solution: Singleton + Promise

One dialog definition, reusable anywhere, lazy-loaded, self-cleaning. The trick is combining a QML Singleton with a JavaScript Promise.

The Singleton

// ConfirmDialog.qml
pragma Singleton

import QtQuick
import QtQuick.Controls

QtObject {
    id: root

    readonly property Component instancer: Component {
        Dialog {
            id: dlg
            anchors.centerIn: Overlay.overlay
            property variant context: null
            property string text: ""
            modal: true
            standardButtons: Dialog.Yes | Dialog.No
            closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside

            onAccepted: { context?.accept?.(); context = null; }
            onRejected: { context?.reject?.(); context = null; }

            Label { width: parent.width; text: dlg.text; visible: text }
        }
    }

    function open(options, parent) {
        return new Promise((resolve, reject) => {
            const context = Object.freeze({
                accept: resolve,
                reject: reject,
            });
            options.context = context;
            const dialog = root.instancer.createObject(parent, options);
            dialog.closed.connect(() => {
                dialog.context?.reject?.();
                dialog.destroy();
            });
            dialog.open();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

context is a plain JS object with accept and reject tied to the Promise. The dialog gets created only when open() is called, and destroy() fires on close — no manual cleanup needed.

The closed signal handles the edge case where the user dismisses via ESC or tapping outside — reject fires only if context hasn't been cleared yet by a button press.

Usage

Button {
    text: "Confirm"
    onClicked: {
        ConfirmDialog.open({
            title: "Delete item",
            text: "This can't be undone.",
        }, root)
        .then(() => doTheDelete())
        .catch(() => console.log("cancelled"))
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. No local state, no property bindings to manage before showing, no dangling instances.

CMake setup

Don't forget to register it as a singleton:

set_source_files_properties(
    qml/ConfirmDialog.qml
    PROPERTIES
        QT_QML_SINGLETON_TYPE TRUE
)
Enter fullscreen mode Exit fullscreen mode

The pattern scales well — swap Dialog for any popup, add fields via options, reuse the same Singleton across the entire app. The Promise interface keeps call sites clean and gives you proper async flow without inventing a custom signal/slot chain every time.

Top comments (0)