Skip to main content

Declarative vs Imperative

By default, a Flet app follows an imperative approach: you directly change control properties like visible and value, add or remove controls, and Flet pushes the change to the page for you. That's fine for small apps, but it doesn't scale well — as a screen grows, the logic for keeping related controls in sync gets duplicated across event handlers, and it's easy to miss one and end up with an inconsistent UI.

Flet also supports a declarative approach: the state of your app is the single source of truth, and the UI is derived from it. You don't change controls or their properties directly; instead, only change application state, by assigning to an observable field or calling a hook's setter. Flet components detect the change and re-render UI that depends on it.

This article shows the same simple "User Manager" app built both ways — imperative and declarative — so you can compare the two approaches directly. It's a classic CRUD app: list users, add a new user, edit inline, and delete:

view
inline edit

Imperative

In this example, clicking a button changes control properties directly (visible, value) and adds or removes controls from the page's control list — that's what makes it imperative. Flet pushes each change to the page automatically.

  • Add creates a new Item row from the first_name/last_name TextField values, appends it to page.controls, and clears both fields.
  • Edit resets the TextFields to the row's first_name/last_name, sets visible=False on text, edit_button, and delete_button, and visible=True on edit_text, save_button, and cancel_button — switching the row into edit mode.
  • Save copies the two TextField values into first_name/last_name and text.value, then sets visible=True on text, edit_button, and delete_button, and visible=False on edit_text, save_button, and cancel_button — returning to read-only mode with the new values.
  • Cancel sets visible=True on text, edit_button, and delete_button, and visible=False on edit_text, save_button, and cancel_button — returning to read-only mode without saving.
  • Delete removes the row's Item instance from page.controls.
play_arrowTry Online
import flet as ft


class Item(ft.Row):
def __init__(self, first_name, last_name):
super().__init__()

self.first_name = first_name
self.last_name = last_name

self.first_name_field = ft.TextField(first_name)
self.last_name_field = ft.TextField(last_name)
self.text = ft.Text(f"{first_name} {last_name}")
self.edit_text = ft.Row(
[
self.first_name_field,
self.last_name_field,
],
visible=False,
)
self.edit_button = ft.Button("Edit", on_click=self.edit_item)
self.delete_button = ft.Button("Delete", on_click=self.delete_item)
self.save_button = ft.Button("Save", on_click=self.save_item, visible=False)
self.cancel_button = ft.Button(
"Cancel", on_click=self.cancel_item, visible=False
)
self.controls = [
self.text,
self.edit_text,
self.edit_button,
self.delete_button,
self.save_button,
self.cancel_button,
]

def delete_item(self, e):
self.page.controls.remove(self)

def edit_item(self, e):
self.first_name_field.value = self.first_name
self.last_name_field.value = self.last_name
self.text.visible = False
self.edit_button.visible = False
self.delete_button.visible = False
self.save_button.visible = True
self.cancel_button.visible = True
self.edit_text.visible = True

def save_item(self, e):
self.first_name = self.first_name_field.value
self.last_name = self.last_name_field.value
self.text.value = f"{self.first_name} {self.last_name}"
self.text.visible = True
self.edit_button.visible = True
self.delete_button.visible = True
self.save_button.visible = False
self.cancel_button.visible = False
self.edit_text.visible = False

def cancel_item(self, e):
self.text.visible = True
self.edit_button.visible = True
self.delete_button.visible = True
self.save_button.visible = False
self.cancel_button.visible = False
self.edit_text.visible = False


def main(page: ft.Page):
page.title = "CRUD Imperative Example"

def add_item(e):
item = Item(first_name.value, last_name=last_name.value)
first_name.value = ""
last_name.value = ""
page.add(item)

first_name = ft.TextField(label="First Name", width=200)
last_name = ft.TextField(label="Last Name", width=200)

page.add(
ft.Row(
[
first_name,
last_name,
ft.Button("Add", on_click=add_item),
]
)
)


if __name__ == "__main__":
ft.run(main)

Declarative

In this example, clicking a button doesn't change controls directly. Instead, it changes application state that exists separately from UI. As soon as the state changes, Flet detects it and re-renders UI.

To understand how Flet stores state and detects when it changes, you need to understand the concepts that lay in the declarative approach: Observables, Components, and Hooks.

Observables

Observables (@ft.observable) are classes whose instances hold your application's state. In this "User Manager" app, each User instance holds one person's name, and a single App instance holds the list of users. The @ft.observable decorator makes their attribute and collection changes trackable: assigning to a field like user.first_name, or changing a list field like app.users.append(...), notifies the components subscribed to it, triggering them to re-render.

@ft.observable
@dataclass
class User:
first_name: str
last_name: str

def update(self, first_name: str, last_name: str):
self.first_name = first_name # notifies subscribed components
self.last_name = last_name

Components

Components (@ft.component) are functions that take arguments — like user and delete_user in UserView(user, delete_user) — and return the controls describing the UI for its current state. Only the arguments that are themselves @ft.observable instances, like user, get subscribed to; a plain callback like delete_user is just passed through.

@ft.component
def UserView(user: User, delete_user) -> ft.Control:
return ft.Row([
ft.Text(f"{user.first_name} {user.last_name}"),
ft.Button("Delete", on_click=lambda: delete_user(user)),
])

Unlike the imperative example, a component doesn't change an existing control's properties or modify page.controls. It just returns a new set of controls each render, and Flet reconciles that against what's already on screen, patching only what changed.

A component renders once when it's first created, and again whenever an observable it's subscribed to — received as an argument, or held via a hook — is changed, or a hook's setter replaces its value; only that component re-renders, not the whole app. On each render, it subscribes again to every such observable. The subscription is to the whole object, not individual fields — which is why, in the example below, editing one user only re-renders its UserView (it is subscribed to that one user), while adding or deleting re-renders all of AppView (it is subscribed to app, and a user list change is a change to app).

Hooks

Hooks (ft.use_state) hold local state scoped to one component instance — like a row's "editing" flag. A plain local variable won't do the job: it resets on every render, and changing it doesn't trigger one. Calling a hook's setter re-renders that component, using the same mechanism as observables — just scoped to one component.

# Broken: a plain local resets on every render, and changing it doesn't trigger one
@ft.component
def UserView(user: User, delete_user) -> ft.Control:
is_editing = False
if not is_editing:
return ft.Row([
ft.Text(f"{user.first_name} {user.last_name}"),
ft.Button("Edit", on_click=lambda: (is_editing := True)), # no re-render
])
...

# Correct: hook state survives across renders and triggers one when set
@ft.component
def UserView(user: User, delete_user) -> ft.Control:
is_editing, set_is_editing = ft.use_state(False)
if not is_editing:
return ft.Row([
ft.Text(f"{user.first_name} {user.last_name}"),
ft.Button("Edit", on_click=lambda: set_is_editing(True)),
])
...

Example

The state lives in instances of two @ft.observable classes: User (first_name, last_name) and App (users: list[User]). @ft.component functions read that state and return controls — UserView renders one row, read-only or editing; AddUserForm renders the add form. Each row's "editing" flag and input buffers are local ft.use_state hooks: they're view-only and don't belong on User.

  • Add (in AddUserForm) calls add_user_and_clear(), which calls app.add_user(...) — appending a User to app.users — then clears its own local use_state buffers. AppView holds app via use_state, so this re-renders AppView, regenerating the whole user list.
  • Edit calls start_edit(), which resets the local buffer hooks to the user's current values and calls set_is_editing(True) — re-rendering just that row's UserView into its editing form.
  • Save calls save(), which calls user.update(...) and set_is_editing(False)user was passed directly into this UserView, so only this one row re-renders with the new values.
  • Cancel calls cancel(), which calls set_is_editing(False) — re-rendering that row back to read-only without touching user.
  • Delete calls app.delete_user(user), removing it from app.users — like Add, this re-renders all of AppView, not just the one row.
declarative data flow diagram
play_arrowTry Online
from dataclasses import dataclass, field

import flet as ft


@ft.observable
@dataclass
class User:
first_name: str
last_name: str

def update(self, first_name: str, last_name: str):
self.first_name = first_name
self.last_name = last_name


@ft.observable
@dataclass
class App:
users: list[User] = field(default_factory=list)

def add_user(self, first_name: str, last_name: str):
if first_name.strip() or last_name.strip():
self.users.append(User(first_name, last_name))

def delete_user(self, user: User):
self.users.remove(user)


@ft.component
def UserView(user: User, delete_user) -> ft.Control:
# Local (transient) editing state—NOT in User
is_editing, set_is_editing = ft.use_state(False)
new_first_name, set_new_first_name = ft.use_state(user.first_name)
new_last_name, set_new_last_name = ft.use_state(user.last_name)

def start_edit():
set_new_first_name(user.first_name)
set_new_last_name(user.last_name)
set_is_editing(True)

def save():
user.update(new_first_name, new_last_name)
set_is_editing(False)

def cancel():
set_is_editing(False)

if not is_editing:
return ft.Row(
[
ft.Text(f"{user.first_name} {user.last_name}"),
ft.Button("Edit", on_click=start_edit),
ft.Button("Delete", on_click=lambda: delete_user(user)),
]
)

return ft.Row(
[
ft.TextField(
label="First Name",
value=new_first_name,
on_change=lambda e: set_new_first_name(e.control.value),
width=180,
),
ft.TextField(
label="Last Name",
value=new_last_name,
on_change=lambda e: set_new_last_name(e.control.value),
width=180,
),
ft.Button("Save", on_click=save),
ft.Button("Cancel", on_click=cancel),
]
)


@ft.component
def AddUserForm(add_user) -> ft.Control:
# Uses local buffers; calls parent action on Add
new_first_name, set_new_first_name = ft.use_state("")
new_last_name, set_new_last_name = ft.use_state("")

def add_user_and_clear():
add_user(new_first_name, new_last_name)
set_new_first_name("")
set_new_last_name("")

return ft.Row(
controls=[
ft.TextField(
label="First Name",
width=200,
value=new_first_name,
on_change=lambda e: set_new_first_name(e.control.value),
),
ft.TextField(
label="Last Name",
width=200,
value=new_last_name,
on_change=lambda e: set_new_last_name(e.control.value),
),
ft.Button("Add", on_click=add_user_and_clear),
]
)


@ft.component
def AppView() -> list[ft.Control]:
app, _ = ft.use_state(
App(
users=[
User("John", "Doe"),
User("Jane", "Doe"),
User("Foo", "Bar"),
]
)
)

return [
AddUserForm(app.add_user),
*[UserView(user, app.delete_user) for user in app.users],
]


def main(page: ft.Page):
page.render(AppView)


if __name__ == "__main__":
ft.run(main)

Conclusion

Imperative and declarative aren't just two ways to write the same code — they're two different ways to think about UI. Imperative asks "what should change on screen right now?" — you list, step by step, exactly what to change for each interaction. Declarative asks "what does my data look like right now?" — you describe the UI as a function of that data, and let Flet figure out what changed on screen.

That shift pays off most as an app gets more interactive:

  • Debugging — since the UI is derived from state, a bug is either "the state is wrong" or "the render is wrong," not "some event handler forgot to update a flag." You can inspect the state directly instead of tracing through a chain of mutations.
  • Testingapp.add_user(...) and user.update(...) are just regular Python methods with no Flet controls involved. You can call them directly in a test and check the result, with no page or rendering required.
  • Multiple views of the same data — if two parts of the UI need to reflect the same state (a badge count, a list, a chart), each just reads from that state instead of being kept in sync by hand.
  • Complex, multi-step UIs — wizards, filters, undo/redo — where many pieces of UI depend on overlapping state, declarative code scales by adding more state and components, not more handler-to-handler coordination.

For more declarative examples, see the declarative examples collection — including a Counter, a To-Do app, and games like Tic-Tac-Toe and Minesweeper. You can also try them interactively in the Flet Studio.