Changes to a param.rx variable do not trigger UI redraws and don't notify watchers consistently

Hey everyone,

below you will find a minimum example that reproduces the troubles I have been having.
In the example I have a Widget with a Button that adds a new entry to a reactive list for every click. The length of the list is then displayed in an “inner” widget.

The expected result is that the console shows a print statement for every button click that counts up the number of entries in the list and the InnerWidget to always shows the current length of the list.

While the console is up to date with every click on the button and correctly counts up, the widget only updates every second click on the button. The table below illustrates the results I am getting. Note that setting up a watcher on test_var results in the same behaviour - only every second click triggers it.

I’d appreciate any guidance on if I am misusing param/panel or if this is unexpected behaviour. I am currently running in panel version 1.4.0 (param version 2.1.1) as DeckGL panes seem unstable and consistently break in any newer versions and they are a core feature of the rest of the app.

Thank you!


Table: console outputs and UI state depending on the amount of times the button was clicked.

number of clicks console output number displayed in the inner widget
0 - 0
1 state was changed and has length 1 0
2 state was changed and has length 2 2
3 state was changed and has length 3 2
4 state was changed and has length 4 4
5 state was changed and has length 5 4
6 state was changed and has length 6 6

Code-Snippet: Minimum code to reproduce the described behaviour

from panel import Column, Row
from panel.widgets import Button
from param import rx

test_var = rx([]) # rx that will store a list of lists

class InnerWidget(Column):
    def __init__(self, **params):
        super().__init__(
            # this for some reason only updates every second time the value
            # in test_var changes
            test_var.rx.len(),
            **params
        )

class OuterWidget(Column):

    def __init__(self, **params):
        super().__init__(
            InnerWidget(),
            Button(
                name="test",
                on_click=self.on_click
            ),
            **params
        )

    def on_click(self, _):
        current_value = test_var.rx.value

        current_value.append(["test"]) # we append a list to the list

        test_var.rx.value = current_value # this should trigger all watchers

        print(f"state was changed and has length {len(test_var.rx.value)}")

OuterWidget().servable()

Hi @Lennart-Rein

Replacing the value works:

test_var.rx.value = test_var.rx.value + ["test"]

Full example

from panel import Column, Row
from panel.widgets import Button
from param import rx

test_var = rx([]) # rx that will store a list of lists

class InnerWidget(Column):
    def __init__(self, **params):
        super().__init__(
            # this for some reason only updates every second time the value
            # in test_var changes
            test_var.rx.len(),
            **params
        )

class OuterWidget(Column):

    def __init__(self, **params):
        super().__init__(
            InnerWidget(),
            Button(
                name="test",
                on_click=self.on_click
            ),
            **params
        )

    def on_click(self, _):
        test_var.rx.value = test_var.rx.value + ["test"]
        print(f"state was changed and has length {len(test_var.rx.value)}.")

OuterWidget().servable()

I’ve requested the expected behaviour documented in Issue #991 · holoviz/param.

Hi Marc,

thanks for helping out! Your proposed fix indeed works. However, I still don’t fully understand when watchers are triggered on reactive objects.

Below, I am providing another example that tests when watchers are triggered on reactive objects. In total, I have 3 test cases:

  1. Reactive object is a list of lists. We add another list to the list by using the ‘+’ operator. This creates a new object with a different value to the original one → When the value and the id of the object is different, the watcher is triggered

  2. Reactive object is a list of lists. We add another list to the list by reading the current value, appending and then re-setting the reactive object to the new list. → When the value of the object changes, but the id doesn’t, the watcher is not triggered.

  3. Reactive object is an empty list. We assign a new empty list to the reactive object. → When the value of the object remains the same but the id is different, the watcher is not triggered.

I find the behaviour in case 3 most counter-intuitive. The documentation on watchers states that a watcher on a value will trigger when the value is set (or more specifically, changed, if onlychanged=True). As I set onlychanged=False I’d have expected that the watcher will trigger even if the value of the object hasn’t changed since it is an entirely different object with a different ID. With this system, I cannot think of any use-case, where setting onlychanged=False provides a different behaviour than onlychanged=True. Am I missing something?

Thanks again!

import param

class CustomObj:
    def __init__(self, val):
        self.val = val

test_var = param.rx([["initial", "list"]])
test_var_2 = param.rx([])

test_var.rx.watch(lambda new_value: print(f"watcher 1 is triggered {new_value}"), onlychanged=False)
test_var_2.rx.watch(lambda new_value: print(f"watcher 2 is triggered {new_value}"), onlychanged=False)

print(f"test 1 - id before: {id(test_var.rx.value)}")
test_var.rx.value = test_var.rx.value + [["some", "new", "list"]]
print(f"test 1 - id after: {id(test_var.rx.value)}")

print("")

print(f"test 2 - id before: {id(test_var.rx.value)}")
current = test_var.rx.value
current.append(["test3"])
test_var.rx.value = current
print(f"test 2 - id after: {id(test_var.rx.value)}")

print("")

print(f"test 3 - id before: {id(test_var_2.rx.value)}")
new_value = []
print(f"test 3 - id of new value: {id(new_value)}")
test_var_2.rx.value = new_value
print(f"test 3 - id after: {id(test_var_2.rx.value)}")

The console output for this code is:

test 1 - id before: …6224
watcher 1 is triggered [[‘initial’, ‘list’], [‘some’, ‘new’, ‘list’]]
test 1 - id after: …5120

test 2 - id before: …5120
test 2 - id after: …5120

test 3 - id before: …4208
test 3 - id of new value: …0368
test 3 - id after: …4208

That is a nice test.

For example 3 the value is changed. The id is different. But Pythons == don’t compare the [] objects different. Therefore the watcher is not fired.

import param

class CustomObj:
    def __init__(self, val):
        self.val = val

test_var = param.rx([["initial", "list"]])
test_var_2 = param.rx([])

test_var.rx.watch(lambda new_value: print(f"watcher 1 is triggered {new_value}"), onlychanged=False)
test_var_2.rx.watch(lambda new_value: print(f"watcher 2 is triggered {new_value}"), onlychanged=False)

print(f"test 1 - id before: {id(test_var.rx.value)}")
test_var.rx.value = test_var.rx.value + [["some", "new", "list"]]
print(f"test 1 - id after: {id(test_var.rx.value)}")

print("")

print(f"test 2 - id before: {id(test_var.rx.value)}")
current = test_var.rx.value
current.append(["test3"])
test_var.rx.value = current
print(f"test 2 - id after: {id(test_var.rx.value)}")

print("")

print(f"test 3 - id before: {id(test_var_2.rx.value)}")
new_value = []
print(test_var_2.rx.value==new_value)
print(f"test 3 - id of new value: {id(new_value)}")
test_var_2.rx.value = new_value
print(f"test 3 - id after: {id(test_var_2.rx.value)}")
test 1 - id before: 139749629908544
watcher 1 is triggered [['initial', 'list'], ['some', 'new', 'list']]
test 1 - id after: 139749629916544

test 2 - id before: 139749629916544
test 2 - id after: 139749629916544

test 3 - id before: 139749646303040
True
test 3 - id of new value: 139749629907776
test 3 - id after: 139749646303040