Reactive Properties Tutorial

In this tutorial you will learn how to make a QObject reactive and QML-bindable with very little code, using the decorators from the PySide6.QtQmlFeatures module: auto_properties(), computed(), watch(), and effect().

We start from a small shopping-cart class written the traditional way, with Property and Signal, and then rewrite it step by step, letting each decorator remove a chunk of boilerplate.

Note

The PySide6.QtQmlFeatures module is currently in technical preview.

The example

The cart has two inputs and one derived value:

  • price and quantity: plain integer inputs.

  • total: always equal to price * quantity.

On top of that we want two reactions whenever something changes:

  • warn when the price jumps by more than 50% (we need the old and new values to decide), and

  • log the cart’s state after any change (we only need the new state).

These three needs map exactly onto the three decorators, as you will see: total becomes a computed(), the price jump warning becomes a watch(), and the logging becomes an effect().

Step 1: The traditional approach

Without the new decorators, every property is declared with the Property decorator and needs a backing attribute, a getter, a setter, and a notify Signal. The derived total has to be recomputed by hand, and the two reactions have to be called explicitly from inside each setter.

 1from PySide6.QtCore import QObject, Property, Signal
 2
 3
 4class Cart(QObject):
 5    # One notify signal per property, declared by hand.
 6    priceChanged = Signal()
 7    quantityChanged = Signal()
 8    totalChanged = Signal()
 9
10    def __init__(self, parent=None):
11        super().__init__(parent)
12        self._price = 10
13        self._quantity = 2
14        self._total = self._price * self._quantity
15
16        # Keep the derived value in sync by hand
17        # whenever an input changes, recompute the total.
18        self.priceChanged.connect(self._recompute_total)
19        self.quantityChanged.connect(self._recompute_total)
20
21    # price: backing field + getter + setter, declared with @Property.
22    @Property(int, notify=priceChanged)
23    def price(self):
24        return self._price
25
26    @price.setter
27    def price(self, value):
28        if self._price == value:
29            return
30        old = self._price
31        self._price = value
32        self.priceChanged.emit()
33        # "watch" behaviour: react to the old and new values by hand.
34        if value > old * 1.5:
35            print(f"warning: price jumped {old} -> {value}")
36        # "effect" behaviour: log the new state by hand.
37        self._log_state()
38
39    # quantity: the same boilerplate, a second time.
40    @Property(int, notify=quantityChanged)
41    def quantity(self):
42        return self._quantity
43
44    @quantity.setter
45    def quantity(self, value):
46        if self._quantity == value:
47            return
48        self._quantity = value
49        self.quantityChanged.emit()
50        self._log_state()
51
52    # total: read-only derived property, kept in sync manually. With no
53    # setter it cannot be written from QML.
54    @Property(int, notify=totalChanged)
55    def total(self):
56        return self._total
57
58    def _recompute_total(self):
59        new_total = self._price * self._quantity
60        if new_total != self._total:
61            self._total = new_total
62            self.totalChanged.emit()
63
64    def _log_state(self):
65        print(f"cart: {self._price} x {self._quantity}")
66
67
68if __name__ == "__main__":
69    cart = Cart()
70    print(f"initial total: {cart.total}")
71    cart.price = 16      # 16 > 10 * 1.5, so the jump warning fires
72    cart.quantity = 3
73    print(f"final total: {cart.total}")

This works, but notice everything you had to write by hand:

  • three Signal declarations,

  • a backing field, a getter, and a setter for price and quantity,

  • a read-only total property plus a _recompute_total slot connected to both inputs,

  • the old/new comparison for the price jump warning, inlined in the setter, and

  • a _log_state call inlined in every setter.

The rest of the tutorial removes these one group at a time.

Step 2: Generate properties with @auto_properties and @computed

auto_properties() is a class decorator. It turns every plain self.<name> = <value> assignment in __init__ into a real Q_PROPERTY with a <name>Changed notify signal, and it turns every computed() method into a cached, read-only property.

 1from PySide6.QtCore import QObject
 2from PySide6.QtQmlFeatures import auto_properties, computed
 3
 4
 5# @auto_properties turns the plain "self.price = ..." / "self.quantity = ..."
 6# assignments into real Q_PROPERTYs with priceChanged/quantityChanged notify
 7# signals, and turns the @computed method into a cached, read-only property.
 8@auto_properties
 9class Cart(QObject):
10    def __init__(self, parent=None):
11        super().__init__(parent)
12        self.price = 10
13        self.quantity = 2
14
15    # 'total' recomputes only when price or quantity changes. No backing
16    # field, no notify signal, no manual recompute wiring.
17    @computed("price", "quantity")
18    def total(self) -> int:
19        return self.price * self.quantity
20
21
22if __name__ == "__main__":
23    cart = Cart()
24    print(f"initial total: {cart.total}")
25    cart.price = 16
26    cart.quantity = 3
27    print(f"final total: {cart.total}")

The @computed("price", "quantity") decorator declares that total depends on price and quantity. The value is cached and recomputed only when one of those dependencies changes; its totalChanged signal is emitted automatically, so any QML binding reading total refreshes on its own. The return annotation (-> int) lets @auto_properties infer the Qt type of the property.

Compared to Step 1, the backing fields, getters, setters, the three explicit Signal objects, and the entire _recompute_total wiring are gone.

Step 3: React to changes with @watch

A watch() method observes a single property. After that property changes, the method is called with a Change describing what happened: it carries the property name, the old value, the new value, and the owner.

 1from PySide6.QtCore import QObject
 2from PySide6.QtQmlFeatures import auto_properties, computed, watch, Change
 3
 4
 5@auto_properties
 6class Cart(QObject):
 7    def __init__(self, parent=None):
 8        super().__init__(parent)
 9        self.price = 10
10        self.quantity = 2
11
12    @computed("price", "quantity")
13    def total(self) -> int:
14        return self.price * self.quantity
15
16    # Runs after 'price' changes. The Change carries the old and new values,
17    # so we can compare them. This replaces the by-hand tracking that lived
18    # inside the manual setter.
19    @watch("price")
20    def on_price_changed(self, change: Change):
21        if change.new > change.old * 1.5:
22            print(f"warning: price jumped {change.old} -> {change.new}")
23
24
25if __name__ == "__main__":
26    cart = Cart()
27    print(f"initial total: {cart.total}")
28    cart.price = 16      # 16 > 10 * 1.5, so the @watch callback warns
29    cart.quantity = 3
30    print(f"final total: {cart.total}")

The price jump warning that lived inside the manual setter in Step 1 is now a self-contained method. Because the Change gives us both change.old and change.new, we can compare them directly. A @watch callback only runs when the value actually changes. Setting a property to its current value does not trigger it.

Note

To watch more than one property, stack several @watch decorators on the same method, one per property.

Step 4: Run side effects with @effect

An effect() runs whenever any of the properties it lists changes. Unlike @watch it receives no Change; it is simply called with self so it can react to the new state.

 1from PySide6.QtCore import QObject
 2from PySide6.QtQmlFeatures import auto_properties, computed, watch, effect, Change
 3
 4
 5@auto_properties
 6class Cart(QObject):
 7    def __init__(self, parent=None):
 8        super().__init__(parent)
 9        self.price = 10
10        self.quantity = 2
11
12    @computed("price", "quantity")
13    def total(self) -> int:
14        return self.price * self.quantity
15
16    @watch("price")
17    def on_price_changed(self, change: Change):
18        if change.new > change.old * 1.5:
19            print(f"warning: price jumped {change.old} -> {change.new}")
20
21    # Runs after price or quantity changes. Unlike @watch it gets no Change;
22    # it just reacts to the new state. This replaces the _log_state() calls
23    # that the manual setters had to make by hand.
24    @effect("price", "quantity")
25    def log_state(self):
26        print(f"cart: {self.price} x {self.quantity}")
27
28
29if __name__ == "__main__":
30    cart = Cart()
31    print(f"initial total: {cart.total}")
32    cart.price = 16
33    cart.quantity = 3
34    print(f"final total: {cart.total}")

The _log_state calls that Step 1 had to sprinkle inside both setters are now a single @effect("price", "quantity") method. Use @watch when you need the before/after values and @effect when you only need to react to the new state. See Comparison: @watch vs @effect in the module reference for a side-by-side comparison.

Run any of these steps directly to see the reactions on the console:

$ python steps/04-effect.py
initial total: 20
warning: price jumped 10 -> 16
cart: 16 x 2
cart: 16 x 3
final total: 48

Step 5: Bind it all from QML

So far the cart has been driven from Python. The real payoff is that the generated properties are ordinary Q_PROPERTY objects, so QML can both read and write them, and the observers fire on changes written from QML too.

The reactive cart running as a QML application

Add the @QmlElement decorator on top of @auto_properties and define the QML_IMPORT_NAME / QML_IMPORT_MAJOR_VERSION variables. Stack the decorators so @auto_properties runs first (innermost): it must add the generated properties to the QMetaObject before @QmlElement registers the type.

 1import sys
 2from pathlib import Path
 3
 4from PySide6.QtCore import QObject
 5from PySide6.QtGui import QGuiApplication
 6from PySide6.QtQml import QQmlApplicationEngine, QmlElement
 7from PySide6.QtQmlFeatures import auto_properties, computed, watch, effect, Change
 8
 9# To be used on the @QmlElement decorator.
10QML_IMPORT_NAME = "Shop"
11QML_IMPORT_MAJOR_VERSION = 1
12
13
14# Stack the decorators so @auto_properties runs first (innermost): it must
15# add the generated Q_PROPERTYs to the QMetaObject before @QmlElement
16# registers the type with the QML engine.
17@QmlElement
18@auto_properties
19class Cart(QObject):
20    def __init__(self, parent=None):
21        super().__init__(parent)
22        self.price = 10
23        self.quantity = 2
24
25    @computed("price", "quantity")
26    def total(self) -> int:
27        return self.price * self.quantity
28
29    @watch("price")
30    def on_price_changed(self, change: Change):
31        if change.new > change.old * 1.5:
32            print(f"warning: price jumped {change.old} -> {change.new}")
33
34    @effect("price", "quantity")
35    def log_state(self):
36        print(f"cart: {self.price} x {self.quantity}")
37
38
39if __name__ == "__main__":
40    app = QGuiApplication(sys.argv)
41    engine = QQmlApplicationEngine()
42    engine.addImportPath(sys.path[0])
43    qml_file = Path(__file__).parent / "cart.qml"
44    engine.load(qml_file)
45    if not engine.rootObjects():
46        sys.exit(-1)
47    exit_code = app.exec()
48    del engine
49    sys.exit(exit_code)

The QML file reads total and writes price and quantity:

 1import QtQuick
 2import QtQuick.Controls
 3import QtQuick.Layouts
 4import Shop
 5
 6ApplicationWindow {
 7    visible: true
 8    width: 320
 9    height: 220
10    title: "Reactive Cart"
11
12    Cart { id: cart }
13
14    ColumnLayout {
15        anchors.centerIn: parent
16        spacing: 12
17
18        // Reads the @computed 'total'. It re-evaluates automatically whenever
19        // price or quantity changes, because each fires its notify signal.
20        Label {
21            text: `Total: ${cart.total}`
22            font.pixelSize: 24
23            Layout.alignment: Qt.AlignHCenter
24        }
25
26        RowLayout {
27            Label { text: "Price:" }
28            // Writing cart.price runs the generated setter, which fires the
29            // @watch and @effect callbacks and invalidates 'total'.
30            SpinBox {
31                from: 1
32                to: 999
33                value: cart.price
34                onValueModified: cart.price = value
35            }
36        }
37
38        RowLayout {
39            Label { text: "Quantity:" }
40            SpinBox {
41                from: 1
42                to: 99
43                value: cart.quantity
44                onValueModified: cart.quantity = value
45            }
46        }
47    }
48}

When you drag a SpinBox, QML writes cart.price (or cart.quantity). That runs the generated setter, which fires the @watch and @effect callbacks on the Python side, invalidates total, and emits the notify signal so the Total label re-evaluates, all without a single hand-written signal connection.

Step 6: Load the view from Python with load_qml_component

Step 5 let QML own the object: Cart { id: cart } instantiates the Python type from QML, and QQmlApplicationEngine.load() brings the whole UI up. This last step shows the mirror image. The reactive Cart stays in Python, and we pull a QML view into Python with load_qml_component, then feed the reactive values into it.

This time there is no @QmlElement on the Cart: QML never creates it. The model is a plain Python object, and a small display-only QML component is loaded and driven from Python.

 1import sys
 2
 3from PySide6.QtCore import QObject, QTimer
 4from PySide6.QtGui import QGuiApplication
 5from PySide6.QtQml import QQmlApplicationEngine
 6from PySide6.QtQmlFeatures import (auto_properties, computed, watch, effect,
 7                                   Change, load_qml_component)
 8
 9
10# The reactive model is unchanged from Step 4: no @QmlElement this time,
11# because the model stays in Python and QML never instantiates it.
12@auto_properties
13class Cart(QObject):
14    def __init__(self, parent=None):
15        super().__init__(parent)
16        self.price = 10
17        self.quantity = 2
18
19    @computed("price", "quantity")
20    def total(self) -> int:
21        return self.price * self.quantity
22
23    @watch("price")
24    def on_price_changed(self, change: Change):
25        if change.new > change.old * 1.5:
26            print(f"warning: price jumped {change.old} -> {change.new}")
27
28    @effect("price", "quantity")
29    def log_state(self):
30        print(f"cart: {self.price} x {self.quantity}")
31
32
33if __name__ == "__main__":
34    app = QGuiApplication(sys.argv)
35    engine = QQmlApplicationEngine()
36
37    # This time the reactive model lives in Python, not in QML.
38    cart = Cart()
39
40    # Pull the QML view into Python with load_qml_component() and create an
41    # instance. 'view' is a Pythonic wrapper whose QML properties are plain
42    # attributes, so we can assign to view.total, view.price, etc.
43    view_factory = load_qml_component(engine, "cartview.qml")
44    view = view_factory.create()
45
46    # Push the reactive values into the loaded QML view. Connecting to the
47    # input notify signals is enough: reading cart.total recomputes the
48    # @computed value on demand.
49    def sync():
50        view.price = cart.price
51        view.quantity = cart.quantity
52        view.total = cart.total
53
54    cart.priceChanged.connect(sync)
55    cart.quantityChanged.connect(sync)
56    sync()
57
58    # Drive the reactive model from Python. Each assignment runs the @watch
59    # and @effect callbacks (console output) and, through sync(), updates the
60    # QML view that was loaded from Python.
61    def tick():
62        cart.price += 2
63        if cart.price > 30:
64            cart.price = 10
65            cart.quantity = cart.quantity % 5 + 1
66
67    timer = QTimer()
68    timer.timeout.connect(tick)
69    timer.start(500)
70
71    exit_code = app.exec()
72    del engine
73    sys.exit(exit_code)

load_qml_component returns a factory; calling create() on it instantiates the component and returns a Pythonic wrapper whose QML properties are plain attributes. That is why view.total and view.price can be assigned directly from sync().

The QML view carries no logic of its own, just three properties that Python writes to:

 1import QtQuick
 2import QtQuick.Controls
 3import QtQuick.Layouts
 4
 5// A pure display component. It has no logic of its own: Python loads it with
 6// load_qml_component(), creates it, and pushes the reactive cart's values
 7// into these three plain properties.
 8ApplicationWindow {
 9    property int price: 0
10    property int quantity: 0
11    property int total: 0
12
13    visible: true
14    width: 320
15    height: 220
16    title: "Reactive Cart (loaded from Python)"
17
18    ColumnLayout {
19        anchors.centerIn: parent
20        spacing: 12
21
22        Label {
23            text: `Total: ${total}`
24            font.pixelSize: 24
25            Layout.alignment: Qt.AlignHCenter
26        }
27
28        Label {
29            text: `price = ${price},  quantity = ${quantity}`
30            Layout.alignment: Qt.AlignHCenter
31        }
32    }
33}

A QTimer nudges cart.price from Python. Each change runs the @watch and @effect callbacks (printed to the console) exactly as before, and sync() reads the recomputed total and pushes it into the QML view loaded from Python. The reactive decorators and load_qml_component work together: the decorators keep the model consistent, while load_qml_component lets Python own and drive the view.

Note

We connect to priceChanged and quantityChanged (the input notify signals) rather than totalChanged. A @computed value is lazy: total is recomputed when it is read, so sync() reads cart.total on demand after either input changes.

Note

load_qml_component is part of the PySide6.QtQmlFeatures technical preview. See the PySide6.QtQmlFeatures module reference for its full description.

Where to go next

For the full description of each decorator, the Change type, @auto_properties, @computed, @watch, and @effect, as well as load_qml_component, see the PySide6.QtQmlFeatures module reference.