Skip to content

v1: Declarative/Reactive approach in Flet #5342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: v1
Choose a base branch
from

Conversation

FeodorFitsner
Copy link
Contributor

@FeodorFitsner FeodorFitsner commented May 29, 2025

This PR introduces a declarative/reactive approach to building UIs in Flet. Developers can now mix imperative and declarative patterns in the same app, enabling more flexible and functional UI code.

Declarative "Counter" example

from dataclasses import dataclass

import flet as ft


@dataclass
class AppState:
    count: int

    def increment(self):
        self.count += 1


def main(page: ft.Page):
    state = AppState(count=0)

    page.floating_action_button = ft.FloatingActionButton(
        icon=ft.Icons.ADD, on_click=state.increment
    )
    page.add(
        ft.ControlBuilder(
            state,
            lambda state: ft.SafeArea(
                ft.Center(ft.Text(value=f"{state.count}", size=50)),
                expand=True,
            ),
            expand=True,
        )
    )


ft.run(main)

Declarative Form example

from dataclasses import dataclass
from typing import cast

import flet as ft

@dataclass
class Form:
    first_name: str = ""
    last_name: str = ""

    def set_first_name(self, value):
        self.first_name = value

    def set_last_name(self, value):
        self.last_name = value

    async def submit(self, e: ft.ControlEvent):
        e.page.show_dialog(
            ft.AlertDialog(
                title="Hello",
                content=ft.Text(f"{self.first_name} {self.last_name}!"),
            )
        )

    async def reset(self):
        self.first_name = ""
        self.last_name = ""


def main(page: ft.Page):
    form = Form()

    page.add(
        ft.ControlBuilder(
            form,
            lambda state: ft.Column(
                cast(
                    list[ft.Control],
                    [
                        ft.TextField(
                            label="First name",
                            value=form.first_name,
                            on_change=lambda e: form.set_first_name(e.control.value),
                        ),
                        ft.TextField(
                            label="Last name",
                            value=form.last_name,
                            on_change=lambda e: form.set_last_name(e.control.value),
                        ),
                        ft.Row(
                            [
                                ft.FilledButton("Submit", on_click=form.submit),
                                ft.FilledTonalButton("Reset", on_click=form.reset),
                            ]
                        ),
                    ],
                )
            ),
        )
    )

ft.run(main)

New features & concepts

Frozen controls

Frozen controls are immutable — they must be re-created via constructors. Their properties cannot be updated after creation. Flet uses deep structural comparison to detect changes in frozen controls and determine whether they need to be re-rendered.

@data_view decorator

Introduces a "UI as a function of state" paradigm.

A function decorated with @data_view accepts one or more inputs (state/data) and returns a Control or a list of Controls. The returned controls are frozen and eligible for diffing and caching.

Example:

@dataclass
class User:
    id: int
    name: str
    age: int
    verified: bool


users = [
    User(1, "John Smith", 20, True),
    User(2, "Alice Wong", 32, True),
    User(3, "Bob Bar", 40, False),
]

@ft.data_view
def user_details(user: User):
    return ft.Card(
        ft.Column(
            [
                ft.Text(f"Name: {user.name}"),
                ft.Text(f"Age: {user.age}"),
                ft.Checkbox(label="Verified", value=user.verified),
            ]
        ),
        list_key=user.id,
    )

@ft.data_view
def users_list(users):
    return ft.Column([user_details(user) for user in users])

When the underlying users list changes, the developer is responsible for re-invoking the users_list() function to generate a new control tree:

page.add(users_list(users))

# Later...

users.append(User(4, name="Someone Else", age=99, verified=False))
page.controls[0] = users_list(users)

del users[1]
page.controls[0] = users_list(users)

list_key property

The list_key parameter helps Flet identify and match items across control lists when comparing old and new frozen control trees. If two controls in the same list share the same list_key, Flet performs a deep comparison to detect changes. This improves performance and preserves state during updates.

If list_key is omitted or None, comparison is done by position and structure.

ControlBuilder control

ControlBuilder is a lightweight reactive control that rebuilds its content every time the UI is updated.

Constructor:

ControlBuilder(state, builder, ...)
  • state: any data object.
  • builder: a function that takes state and returns a Control.

See Counter example above.

Other changes

Event handlers without e

Simple event handlers can now omit e parameter, for example both of these work:

button_1.on_click = lambda: print("Clicked!")
button_2.on_click = lambda e: print("Clicked!", e)

or

def increment():
   print("Increment clicked")

inc_btn.on_click = increment

@FeodorFitsner FeodorFitsner requested a review from ndonkoHenri May 29, 2025 17:55
@@ -95,9 +95,10 @@ def new_post_init(self: T, *args):

@dataclass(kw_only=True)
class BaseControl:
_i: int = field(init=False)
_i: int = field(init=False, compare=False)
_c: str = field(init=False)
data: Any = skip_field()
Copy link
Contributor

@ndonkoHenri ndonkoHenri May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at skip_field's definition, this will mean,

data: Any = field(default=None, repr=False, compare=False, metadata={"skip": True})
  • repr=False: why shouldn't this prop appear in prints ?
  • compare=False: will this mean that 2 Controls with different data values will pass the == check? I dont think it should be the case.

Test Code:

from dataclasses import dataclass, field

@dataclass(kw_only=True)
class Example:
    name: str = "henri"
    data: int = field(default=None, repr=False, compare=False)

a = Example(data=10)
b = Example(data=20)

# repr only includes 'name'
print(a)  # Example(name='henri')

# Comparison uses only 'name'
print(a == b)  # True (though different 'data')

Suggestion:

data: Any = field(default=None, metadata={"skip": True})

commit 345d6ee
Author: Feodor Fitsner <[email protected]>
Date:   Sat Jun 7 17:46:06 2025 -0700

    Flutter 3.32.2

commit 91e657b
Author: Feodor Fitsner <[email protected]>
Date:   Sat Jun 7 12:49:34 2025 -0700

    Object patcher fixes. Navigation controls fixed.

commit bc3f289
Author: Feodor Fitsner <[email protected]>
Date:   Fri Jun 6 11:37:53 2025 -0700

    OptionalEventCallable allow handlers with `e` arg and without

commit eec74c1
Author: Feodor Fitsner <[email protected]>
Date:   Fri Jun 6 11:30:41 2025 -0700

    Fix dart tests

commit 4e33aa0
Author: Feodor Fitsner <[email protected]>
Date:   Fri Jun 6 11:15:40 2025 -0700

    Remove Center control

commit f00506a
Author: Feodor Fitsner <[email protected]>
Date:   Fri Jun 6 11:00:32 2025 -0700

    One more test and cleanup

commit 2f0e3ba
Author: Feodor Fitsner <[email protected]>
Date:   Fri Jun 6 10:55:44 2025 -0700

    All python tests are passing

commit 3aede43
Author: Feodor Fitsner <[email protected]>
Date:   Thu Jun 5 17:08:15 2025 -0700

    Fix mount/unmount for frozen controls

commit f4d14a0
Author: Feodor Fitsner <[email protected]>
Date:   Thu Jun 5 14:05:43 2025 -0700

    NEW Control.update and Control.applyPatch methods

commit decc499
Author: Feodor Fitsner <[email protected]>
Date:   Thu Jun 5 09:40:01 2025 -0700

    Python part done

commit 796f4c4
Author: Feodor Fitsner <[email protected]>
Date:   Thu Jun 5 08:53:14 2025 -0700

    operation path is always two elements

commit 96b1fb4
Author: Feodor Fitsner <[email protected]>
Date:   Wed Jun 4 19:55:20 2025 -0700

    Frozen tests passing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants