Thanks to visit codestin.com
Credit goes to github.com

Skip to content

bug: use_state doesn't detect in-place mutation when updater returns same object #6225

@3mora2

Description

@3mora2

Duplicate Check

Describe the bug

When using ft.use_state() with an updater function that mutates the current state in-place and returns the same object reference, the component does not re-render.

In set_state, new_value is computed by calling:

new_value_or_fn(hook.value)

If updater mutates hook.value directly (e.g., list.append()), then hook.value is already changed before comparison.
So this check may fail to detect change:

if new_value != hook.value:

because both refer to the same mutated object.

Code sample

Code
import asyncio
import flet as ft


@ft.component
def App():
    items, set_items = ft.use_state(list(range(3)))

    async def add_new(e):
        def add_item(cur: list):
            # in-place mutation
            cur.append(len(cur))
            return cur

        set_items(add_item)
        await asyncio.sleep(0.1)

    return ft.Column(
        controls=[
            ft.Text(f"Count: {len(items)}"),
            ft.Column([ft.Text(str(i)) for i in items]),
            ft.OutlinedButton("Add new", on_click=add_new),
        ]
    )


ft.run(lambda page: page.render(App))

To reproduce

  • Run the repro code.

  • Click "Add new".

  • Observe that state content is mutated in-place, but UI may not update/re-render as expected.

Expected behavior

State update should trigger component re-render when updater function changes content, even if it returns the same object reference after in-place mutation.

Screenshots / Videos

Captures

[Upload media here]

Operating System

Windows

Operating system details

Windows 11 ###

Flet version

0.81.0

Regression

No, it isn't

Suggestions

Possible fix in set_state:

  • Keep a snapshot of old value before calling updater (for callable updaters), then compare against snapshot when same object reference is returned.

  • Example logic:

    • old_value = hook.value
    • old_snapshot = deepcopy(old_value) (for updater path)
    • new_value = updater(hook.value)
    • if new_value is old_value, compare new_value != old_snapshot; else compare new_value != old_value.

This correctly handles both:

  1. Immutable replacement (return new_list)
  2. In-place mutation (cur.append(...); return cur)
    def set_state(new_value_or_fn: StateT | Updater):
        """
        Update the state value.

        Can be called with either:
          - a direct new value, or
          - a function that takes the current value and returns the next one.
        """
        # Compute next value
        old_value = hook.value
        is_updater = callable(new_value_or_fn)

        # If updater mutates value in-place and returns the same object,
        # `old_value` reference will also reflect the mutation.
        # Keep a snapshot to correctly detect that change.
        old_value_snapshot = deepcopy(old_value) if is_updater else old_value

        new_value = (
            new_value_or_fn(hook.value)
            if is_updater
            else new_value_or_fn
        )

        # Trigger update only when value changed.
        # Handles both replacement and in-place mutation patterns.
        if is_updater and new_value is old_value:
            value_changed = new_value != old_value_snapshot
        else:
            value_changed = new_value != old_value

        if value_changed:
            hook.value = new_value
            update_subscription(hook)
            hook.version += 1
            if hook.component:
                hook.component._schedule_update()

Logs

Logs
[Paste your logs here]

Additional details

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions