Skip to content

From Imperative to Declarative in Flet: Migrating a Simple CRUD “User Manager”#

If you’ve been using Flet, you’ve probably built your apps the imperative way, maybe without even noticing. You flip visibility flags, set control values, update lists of controls and call page.update() - that is the imperative approach, meaning you change UI directly when handling events. Flet now supports a declarative style: stop mutating controls, change state instead, and Flet updates the UI automatically.

We’ll show the switch using a tiny CRUD “User Manager” app. First, the imperative version: UI-first, mutate controls, then update the page. Then the declarative rewrite: model-first, observable classes for data, components that return UI from state.

The behavior in both examples stays the same - in the app you can see the list of users, add user, inline edit with save/cancel buttons and delete. This is how this simple app looks in both examples:

view

After clicking inline Edit button:

view

Example 1 — Imperative#

In the imperative version, you think UI-first: decide exactly how the screen should look, and how it should change on each button click. Event handlers directly toggle control properties (like visible, value), insert/remove controls, and then call page.update() to push those visual changes. Edit hides the read-only label and action buttons, shows inputs and Save/Cancel; Save copies text field values back into the label and restores the original view; Cancel just restores the original view; Delete removes the whole row from the page. In short, behavior is implemented by mutating controls and manually triggering re-renders, not by evolving a separate state model.

import flet as ft   class Item(ft.Row):  def __init__(self, first_name, last_name):  super().__init__()   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)  self.page.update()   def edit_item(self, e):  print("edit_item")  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  self.page.update()   def save_item(self, e):  self.text.value = f"{self.first_name_field.value} {self.last_name_field.value}"  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  self.page.update()   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  self.page.update()   def main(page: ft.Page):  page.title = "CRUD Imperative Example"   def add_item(e):  item = Item(first_name.value, last_name=last_name.value)  page.add(item)  first_name.value = ""  last_name.value = ""  page.update()   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),  ]  )  )   ft.run(main) 

Example 2 — Declarative#

In the declarative version, you think model-first: the model is a set of classes, and the data their objects hold is the single source of truth. In our CRUD app, the model consists of User (persisted fields first_name, last_name) and a top-level App that owns users: list[User] plus actions like add_user(first, last) and delete_user(user). Both classes are marked @ft.observable, so assigning to their attributes (e.g., user.update(...), app.users.remove(user)) triggers re-rendering — no page.update().

The UI is composed as components marked with @ft.component that return a view of the current state. Each row decides whether to show a read-only view or an inline editor using its own short-lived, local values (hooks), while the durable data lives on the model objects. Event handlers update state only (e.g., modify a user or add/remove items), not the controls themselves; Flet detects those changes and re-renders the affected parts. In short: UI = f(state), with User and App providing the authoritative data.

diagram

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],  ]   ft.run(lambda page: page.render(AppView)) 

Mindset shift: UI = f(state)#

The core idea is determinism: given the same state, your component should return the same UI. Think in two phases:

  1. Handle event → update state Event handlers change data only (e.g., set_is_editing(True), user.update(...)). They don’t hide/show controls or call page.update().

  2. Render → derive UI from state The component returns controls based on the current state snapshot. Because models are @ft.observable and locals come from ft.use_state, Flet re-runs the component when state changes and re-renders the right subtree.

Declarative Building Blocks: Observables, Components, Hooks#

Below are the key pieces of the Flet framework that make the declarative approach work:

Observables — your source of truth#

@ft.observable marks a dataclass as reactive. When you assign to its fields (user.first_name = "Ada" or app.users.append(user)), Flet re-renders any components that read those fields - no page.update() calls. Use observables for persisted/domain data (things you actually save).

from dataclasses import dataclass, field import flet as ft  @ft.observable @dataclass class User:  first_name: str  last_name: str  @ft.observable @dataclass class AppState:  users: list[User] = field(default_factory=list) 

Components — functions that return UI#

@ft.component turns a function into a rendering unit. It takes props (regular args), may use hooks, and returns controls that describe the UI for the current state. Components do not imperatively mutate the page tree; they just return what the UI should look like now.

import flet as ft  @ft.component def UserRow(user: User, on_delete) -> ft.Control:  # returns a row for the current snapshot of `user`  return ft.Row([  ft.Text(f"{user.first_name} {user.last_name}"),  ft.Button("Delete", on_click=lambda _: on_delete(user)),  ]) 

Hooks — local, short-lived UI state#

Why they are needed: components are functions that re-run on every render. Plain local variables get reinitialized each time, and changing them doesn’t tell Flet to update the view. Hooks (e.g., ft.use_state) give a component a place to persist values across renders and a way to signal a re-render when those values change.

What hooks solve

  • Persistence: locals reset on each render; hook state survives.
  • Reactivity: modifying a local doesn’t refresh the UI; a hook’s setter schedules a re-render.
  • Fresh values in handlers: event callbacks won’t see stale locals; they read the latest hook state.

Use hooks for short-lived, view-only concerns (like an “is editing?” flag or current input text) that belong to a single component. Use observables for durable app/domain data shared across components.

Example

# Broken: local resets every render and doesn't trigger updates @ft.component def CounterBroken():  count = 0  return ft.Row([  ft.Text(str(count)),  ft.Button("+", on_click=lambda _: (count := count + 1)), # no re-render  ])  # Correct: persists across renders and re-renders when updated @ft.component def Counter():  count, set_count = ft.use_state(0)  return ft.Row([  ft.Text(str(count)),  ft.Button("+", on_click=lambda _: set_count(count + 1)),  ]) 

Rule of thumb: if a value must survive re-renders and updating it should change the UI, don’t use a plain local — use hook state (for local UI) or an observable (for shared, persisted data).

Rewrite recipes (imperative → declarative)#

1) Visibility toggles → Conditional rendering#

# Imperative self.text.visible = False self.save_button.visible = True self.page.update()  # Declarative return (  ft.Row([...read-only...])  if not is_editing  else ft.Row([...edit form...]) ) 

2) Direct control mutation → Model mutation#

# Imperative self.text.value = f"{first} {last}"  # Declarative user.update(new_first_name, new_last_name) 

3) page.update() everywhere → Nowhere#

  • Imperative handlers end with page.update().
  • Declarative code updates observable fields or use_state values and lets Flet re-render.

4) Handlers manipulate state, not the view#

# Declarative example set_is_editing(True) set_new_first_name(user.first_name) 

5) Extract UI into components#

  • UserView = one row (read-only/editing)
  • AddUserForm = small, reusable add form

Summary#

The declarative style makes your UI a straightforward function of your data. It may not be make a big difference for a very simple app, but as your screen grows, you’ll add state and components, not scattered mutations of controls in different places. The result: code that’s easier to understand, maintain, and change — without chasing visible flags or manual updates.