Qt Model/View: When to Separate Data from Presentation in C++ Apps
Qt's Model/View separates data from presentation — but adds boilerplate. Learn when to switch from QListWidget to QAbstractListModel, how to layer QSortFilterProxyModel for live filtering, and the performance traps to avoid.
06 Sept 2025, 21:21 UTC

The problem: widget-based lists hit a wall
You start with a QListWidget or QTableWidget because it’s quick: add items, set text, done. Then the requirements grow — sorting by multiple columns, live filtering from a search box, sharing the same data between a table and a tree view, or moving the data layer to a background thread. Suddenly you’re subclassing QListWidgetItem to stash pointers, connecting dozens of signals manually, and fighting the widget’s internal storage. That’s the moment Qt’s Model/View architecture pays off.
Core classes and the minimal model
The framework centers on QAbstractItemModel, an interface that views (QListView, QTableView, QTreeView) consume without knowing your data structure. For list or table data, prefer QAbstractListModel or QAbstractTableModel — they provide default index(), parent(), and rowCount()/columnCount() implementations, so you only override what matters.
Required overrides for a read-only list model
class ContactModel : public QAbstractListModel {
Q_OBJECT
public:
enum Roles { NameRole = Qt::UserRole + 1, EmailRole, PhoneRole };
int rowCount(const QModelIndex &parent = {}) const override {
return parent.isValid() ? 0 : contacts.size();
}
QVariant data(const QModelIndex &index, int role) const override {
if (!index.isValid() || index.row() >= contacts.size()) return {};
const auto &c = contacts[index.row()];
switch (role) {
case NameRole: return c.name;
case EmailRole: return c.email;
case PhoneRole: return c.phone;
default: return {};
}
}
QHash roleNames() const override {
return { {NameRole, "name"}, {EmailRole, "email"}, {PhoneRole, "phone"} };
}
// mutating helpers — call begin/end pairs so views update
void addContact(const Contact &c) {
beginInsertRows({}, contacts.size(), contacts.size());
contacts.push_back(c);
endInsertRows();
}
private:
QVector contacts;
};
Key points: data() is called frequently during painting and scrolling — keep it light. Cache formatted strings if needed. Use Qt::DisplayRole for generic string display and custom roles (via Qt::UserRole+) for typed data. Always emit changes through beginInsertRows/endInsertRows, beginRemoveRows/endRemoveRows, or dataChanged after modifying internal state.
Worked example: sortable, filterable contact list
Layer a QSortFilterProxyModel between your model and the view to get sorting and filtering without touching the source model.
// In your widget/window setup
ContactModel *source = new ContactModel(this);
source->addContact({"Alice", "[contact removed]", "555-0101"});
source->addContact({"Bob", "[contact removed]", "555-0102"});
// ... more contacts
QSortFilterProxyModel *proxy = new QSortFilterProxyModel(this);
proxy->setSourceModel(source);
proxy->setFilterRole(ContactModel::NameRole);
proxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxy->setSortRole(ContactModel::NameRole);
proxy->sort(0, Qt::AscendingOrder);
QListView *view = new QListView(this);
view->setModel(proxy);
view->setItemDelegate(new ContactDelegate(this)); // custom delegate paints name/email/phone
// Live filter from a QLineEdit
connect(searchLineEdit, &QLineEdit::textChanged,
proxy, &QSortFilterProxyModel::setFilterRegularExpression);
The proxy model handles filtering and sorting entirely in the view layer — the source model stays unchanged. setFilterRegularExpression accepts a QRegularExpression, so you can do partial matches, case-insensitive search, or even regex patterns.
Performance and common pitfalls
- Heavy
data(): Avoid allocations, formatting, or database lookups indata(). Pre-compute display strings when the underlying data changes, or cache them in a parallel structure. - Missing
dataChanged: If you implementsetData()for editing, you must emitdataChanged(index, index, {roles})with the exact role set. Forgetting this is the #1 reason views don’t update. - Proxy limitations:
QSortFilterProxyModelfilters/sorts on a single role/column by default. Multi-column sorting requires subclassinglessThan()or usingQStandardItemModelwithsetSortRole(). - Thread affinity: Models must live in the same thread as the view (or the QML engine). Moving a model to a worker thread works only if you emit signals from that thread — Qt uses queued connections automatically, but you must ensure the model’s lifetime and thread-safety.
Trade-off: boilerplate vs. flexibility
For a static list of <100 items with no sorting, filtering, or multiple views, QListWidget/QTableWidget are simpler and faster to write. Model/View adds ~50 lines of boilerplate (class definition, role enum, virtual overrides) and indirection. The payoff appears when you need:
- Multiple views on the same data (table + chart + export)
- Live filtering/sorting without duplicating data
- Data sourced from a database, network, or background thread
- QML integration — the same C++ model exposes directly to QML via
qmlRegisterType
Actionable checklist for your next decision
- If the dataset is small, static, and single-view → use widget item classes.
- If you need sorting, filtering, or multiple views → start with
QAbstractListModel+QSortFilterProxyModel. - Implement
roleNames()for QML-friendly role access; keepdata()O(1). - Test with 1,000+ rows: profile
data()call count during scrolling (should scale with visible rows, not total). - Verify updates: edit a cell, confirm view refreshes without manual repaint calls.
Qt 6 keeps the same API stable since Qt 4 — the patterns above work identically in Widgets and QML. The architecture isn’t magic, but it turns UI churn into a maintainable data layer.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.