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();
});
}
}
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"))
}
}
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
)
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)