Qt Model/View: Building a Thread-Safe List Model for C++ and QML
Build a thread-safe Qt list model by subclassing QAbstractListModel, exposing it to QML, and marshaling background updates via queued signals. Covers required virtuals, editable roles, structural change signals, and common pitfalls.
01 Aug 2025, 11:33 UTC

The Problem: Data and Presentation Coupled
Qt applications often embed data logic directly inside widgets or QML components. This makes testing hard, prevents reuse across multiple views, and forces UI code to know storage details. The Model/View framework solves this by defining a contract: the model owns data and notifies changes; the view only asks for data via indexes and roles.
Useful Takeaway
Subclass QAbstractListModel for flat lists, implement the three required virtuals (rowCount, data, roleNames), and expose the model to QML through a context property or qmlRegisterType. For edits, implement setData and emit dataChanged. For structural changes, wrap mutations in beginInsertRows/endInsertRows (or remove equivalents). All model access must stay on the GUI thread — use queued signals from workers.
Minimal Read-Only List Model (C++)
Header stringlistmodel.h:
#include <QAbstractListModel>
#include <QStringList>
class StringListModel : public QAbstractListModel {
Q_OBJECT
public:
explicit StringListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void addItem(const QString &text);
Q_INVOKABLE void removeItem(int index);
private:
QStringList m_items;
};
Implementation stringlistmodel.cpp:
#include "stringlistmodel.h"
StringListModel::StringListModel(QObject *parent)
: QAbstractListModel(parent) {}
int StringListModel::rowCount(const QModelIndex &parent) const {
return parent.isValid() ? 0 : m_items.size();
}
QVariant StringListModel::data(const QModelIndex &index, int role) const {
if (!index.isValid() || index.row() >= m_items.size())
return {};
if (role == Qt::DisplayRole)
return m_items.at(index.row());
return {};
}
QHash<int, QByteArray> StringListModel::roleNames() const {
return {{Qt::DisplayRole, "display"}};
}
void StringListModel::addItem(const QString &text) {
const int row = m_items.size();
beginInsertRows(QModelIndex(), row, row);
m_items.append(text);
endInsertRows();
}
void StringListModel::removeItem(int index) {
if (index < 0 || index >= m_items.size())
return;
beginRemoveRows(QModelIndex(), index, index);
m_items.removeAt(index);
endRemoveRows();
}
Register the type in main.cpp before loading QML:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "stringlistmodel.h"
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
qmlRegisterType<StringListModel>("App", 1, 0, "StringListModel");
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
return app.exec();
}
QML Consumer
main.qml uses the registered type and binds to the display role:
import QtQuick 2.15
import QtQuick.Controls 2.15
import App 1.0
ApplicationWindow {
width: 400
height: 600
visible: true
title: "Model/View Demo"
StringListModel {
id: model
Component.onCompleted: {
model.addItem("Apple")
model.addItem("Banana")
model.addItem("Cherry")
}
}
ListView {
anchors.fill: parent
model: model
delegate: ItemDelegate {
width: ListView.view.width
text: display
onClicked: model.removeItem(index)
}
}
}
The delegate receives display because roleNames() mapped Qt::DisplayRole to the byte array "display". The index property is provided automatically by ListView.
Making the Model Editable
Add setData and handle Qt::EditRole:
bool StringListModel::setData(const QModelIndex &index,
const QVariant &value,
int role) {
if (!index.isValid() || index.row() >= m_items.size())
return false;
if (role != Qt::EditRole)
return false;
m_items[index.row()] = value.toString();
emit dataChanged(index, index, {role});
return true;
}
In QML, an editable delegate can write back:
delegate: TextField {
text: display
onAccepted: model.setData(index, text, Qt.EditRole)
}
Note: Qt::EditRole is distinct from Qt::DisplayRole. Views request EditRole when an editor opens; implement both for a smooth experience.
Thread Safety: Marshaling from Workers
The model lives on the GUI thread. A background worker must not call beginInsertRows directly. Instead, emit a signal connected with Qt::QueuedConnection (default for cross-thread connections) to a slot on the model:
// Worker in a QThread
class DataLoader : public QObject {
Q_OBJECT
public slots:
void load() {
QStringList results = fetchFromNetwork(); // blocking
emit loaded(results);
}
signals:
void loaded(const QStringList &items);
};
// In model
public slots:
void onLoaded(const QStringList &items) {
beginInsertRows(QModelIndex(), 0, items.size() - 1);
m_items = items;
endInsertRows();
}
// Wiring (GUI thread)
DataLoader *loader = new DataLoader;
QThread *thread = new QThread;
loader->moveToThread(thread);
connect(thread, &QThread::started, loader, &DataLoader::load);
connect(loader, &DataLoader::loaded, model, &StringListModel::onLoaded);
thread->start();
Run with QT_LOGGING_RULES="qt.modelview.debug=true" (Qt 6.5+) to see internal model/view diagnostics.
Common Mistakes
- Forgetting
emit dataChanged(index, index, {role})aftersetData— views won't refresh. - Modifying
m_itemswithoutbeginInsertRows/endInsertRows— view indexes become invalid, crashes or blank rows appear. - Returning an invalid
QVariant()for unhandled roles instead of{}(default-constructed). Both work, but{}is clearer and avoids implicit conversions. - Blocking in
data()— this runs on the GUI thread during painting; keep it O(1). - Missing
roleNames()for custom roles — QML bindings silently fail. - Storing
QModelIndexlong-term — indices invalidate on any structural change. UseQPersistentModelIndexif you must retain a reference. - Calling
beginResetModel()/endResetModel()for single-row changes — forces full view rebuild. Prefer fine-grained insert/remove.
Limits and Verification
- Single-threaded model: All slots that mutate the model must execute on the GUI thread. Verify by running the worker test above and confirming no crashes or
QObject::setParent: Cannot set parent, new parent is in a different threadwarnings. - Large datasets:
QAbstractItemModelcreates an index per request. For 100k+ rows, implementcanFetchMore()/fetchMore()for incremental loading, or consider a custom model that reuses index objects. - Delegate churn: QML delegates are destroyed and recreated on scroll. Avoid heavy initialization (e.g., creating child loaders, complex bindings) in the delegate; move expensive work to the model or a singleton service.
- Verification steps:
- Build a Qt Widgets project with
QListView+StringListModel; add/remove items via buttons and confirm view updates. - Expose the same model to QML via
setContextPropertyorqmlRegisterType; use aListViewwith aTextdelegate binding todisplay. - Run the threaded loader test; watch for UI updates without freezes.
- Enable
QT_LOGGING_RULES=qt.modelview.debug=trueand inspect console output for unexpectedlayoutChangedor missingdataChangedemissions.
- Build a Qt Widgets project with
Quick Reference: Required Virtuals by Model Type
| Base Class | Required Virtuals | Typical Use |
|---|---|---|
| QAbstractListModel | rowCount, data, roleNames | Flat lists (QML ListView, QListView) |
| QAbstractTableModel | rowCount, columnCount, data, headerData, roleNames | Tables (QTableView, QML TableView) |
| QAbstractItemModel | index, parent, rowCount, columnCount, data, roleNames | Trees (QTreeView) |
Start with QAbstractListModel unless you need columns or hierarchy. The framework handles the rest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.