Skip to main content

Introducing Declarative UI in Flet

· 9 min read
Feodor Fitsner
Flet founder and developer

Flet 1.0 is about more than a facelift. Our goal is to help Python developers build production-grade apps that scale from a handful of screens to hundreds of pages, views, and dialogs.

Dogfooding Flet — building our own products like the Flet mobile app and the Control Gallery — made it clear that the imperative approach becomes hard to manage as apps grow.

That’s why Flet 1.0 introduces a declarative approach alongside the existing imperative API, drawing inspiration from frameworks such as React, SwiftUI, and Jetpack Compose.

Here's a quick look at a counter app written declaratively:

import flet as ft

@ft.component
def App():
count, set_count = ft.use_state(0)

return ft.Row(
controls=[
ft.Text(value=f"{count}"),
ft.Button("Add", on_click=lambda: set_count(count + 1)),
],
)

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

Keep reading to see how it works and how you can start using it today.

What is imperative UI

Imperative UI is when you tell the framework exactly how to build and update the interface step by step. You manipulate the UI directly — create controls, change their properties, insert or remove them in response to user actions.

For example, in an imperative style you might write:

“Create a button, then when it's clicked, change the label's text and move it below the image.”

left_column.visible = False
right_column.visible = True
right_column.controls.append(ft.Text("Complete!"))

Flet has championed imperative UI from the beginning, and we still believe it is a valid and straightforward approach — especially for small apps or developers without frontend experience.

The problem with the imperative approach, though, is that the app's state, logic, and UI all live in the same place. You constantly have to synchronize the app state and every UI element that depends on it. Add a new user to a list? You also have to add a corresponding ft.Row to display that record. As your app grows, the number of spots that must stay synchronized grows exponentially.

What is declarative UI

The declarative approach means you describe what the UI should look like for a given state, not how to build or update it. Instead of manually creating, changing, or removing controls, you write a function that returns the UI structure based on current data — and the framework figures out the minimal updates needed to make it real.

In other words, your UI becomes a pure expression of state: whenever the state changes, the framework re-renders the view so it always stays consistent.

UI = f(state)

This makes the code simpler, more predictable, and easier to reason about.

Declarative Hello World

Here's a simple declarative "Hello, world" Flet app:

import flet as ft

def App():
return ft.Text("Hello, world!")

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

Your app must be declarative from top to bottom, similar to how async code needs to remain async all the way. The new page.render() bootstrap method makes that possible.

For clarity, without using lambdas, the code can be rewritten as:

@ft.component
def App():
return ft.Text("Hello, Flet!")

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

ft.run(main) # as before

This app does nothing fancy — it simply displays the message and does not respond to user actions in any way.

Declarative Counter

Here's a simple declarative "counter" Flet app:

import flet as ft

@ft.component
def App():
count, set_count = ft.use_state(0)

return ft.Row(
controls=[
ft.Text(value=f"{count}"),
ft.Button("Add", on_click=lambda: set_count(count + 1)),
],
)

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

You may notice a couple of new ideas here: the @component decorator and the use_state() hook — we explain both shortly.

The takeaway is that the App function is a component that returns a fresh UI (Row) every time the app's state changes.

Components

In Flet's declarative approach, a component is simply a reusable function that describes a piece of UI as a function of state.

You can think of it as a self-contained unit that takes inputs (properties, data, event handlers) and returns Flet controls — like Column, Text, Button, etc. Every time its inputs or internal state change, the component rebuilds its UI, and Flet automatically updates only the changed parts.

Example:

@ft.component
def Counter(value, on_increment):
return ft.Row([
ft.Text(f"Count: {value}"),
ft.Button("Increment", on_click=on_increment)
])

Use the @component decorator to mark a function as a component.

Controls vs Components

A control is a UI element — the basic building block rendered on screen. It's a concrete thing like a Text, Button, Row, or Column. Controls have properties (e.g., text, color, alignment) and can contain child controls.

Example:

ft.Text("Hello")
ft.Button("Click me")
ft.Column([ft.Text("A"), ft.Text("B")])

A component is a piece of logic that builds and returns controls. It's not rendered directly — instead, it describes how to create controls based on inputs or state. Components let you group logic, reuse UI patterns, and define your own higher-level abstractions.

Example:

@ft.component
def Greeting(name):
return ft.Text(f"Hello, {name}!")

Here Greeting() is a component, and ft.Text is a control. You can combine controls inside components, and combine components to form bigger ones — but only controls end up in the final UI tree that Flet renders.

Hooks

Hooks are lightweight functions that let components store state, react to lifecycle events, or access shared context — all without writing classes or managing manual state objects.

Example:

@ft.component
def Counter():
count, set_count = ft.use_state(0)

return ft.Row(
controls=[
ft.Text(value=f"{count}"),
ft.Button("Add", on_click=lambda: set_count(count + 1)),
],
)

Here:

  • The Counter() component reads like a simple function.
  • use_state(0) gives it persistent state.
  • When set_count() is called, Flet re-runs the component and re-renders only what changed.

That persistence is crucial: ordinary local variables are re-created on every render, so their values would disappear. Hook state survives re-renders, giving your functional components memory without resorting to globals or classes.

To better understand what hooks are (in an OOP analogy), imagine the Counter is a class, not a function. In pseudo-code the example above becomes:

class Counter(Component):
count: state(0)

def build(self):
return Row(...)

Here, count is a field that holds the current counter state.

Hooks are a smart way to add state and behavior to functional, stateless-looking components. The idea is not unique to Flet; we borrowed it from React.

Flet offers the following built-in hooks:

  • use_state - Store local state across rebuilds.
  • use_effect - Run side effects when something changes.
  • use_context - Access shared data or services.
  • use_memo - Memoize computed values.

Observable

Observables make the declarative UI approachable for newcomers compared to a purely React-style model. You can find observables in frameworks such as SolidJS, SwiftUI, and Jetpack Compose.

An observable is a reactive data holder that keeps your UI in sync automatically — whenever its value changes, the corresponding parts of the UI update instantly and efficiently.

There are two ways to make a class observable:

Inherit from ft.Observable:

@dataclass
class CounterState(ft.Observable):
count: int

Apply ft.observable decorator:

@dataclass
@ft.observable
class CounterState:
count: int

Observables fit nicely into Flet's declarative approach:

  • A component that accepts an observable parameter automatically re-renders when that observable updates.
  • use_state and use_context hooks that reference observables trigger a re-render when the observable changes.

Example:

import asyncio
from dataclasses import dataclass

import flet as ft

@dataclass
@ft.observable
class AppState:
counter: float

async def start_counter(self):
self.counter = 0
for _ in range(0, 10):
self.counter += 0.1
await asyncio.sleep(0.5)


@ft.component
def App():
state, _ = ft.use_state(AppState(counter=0))

return [
ft.ProgressBar(state.counter),
ft.Button("Run!", on_click=state.start_counter),
]

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

Here, AppState is observable state, and whenever its counter property updates, the App component re-renders.

Compared to a pure React model, an observable makes life easier by allowing mutable state, while React assumes immutable state that must be replaced entirely to trigger a re-render.

For better performance, multiple updates to observable properties are coalesced, resulting in fewer UI updates when control returns to the UI loop.

Examples

Explore the declarative examples collection to see the new approach in action — from the simple Counter and classic To-Do to games like Tic-Tac-Toe, Minesweeper, and Solitaire.

For a deeper dive, walk through the Declarative vs Imperative CRUD app cookbook.

Minesweeper game built with declarative Flet components

FAQ

Do I need to rewrite my existing Flet apps in declarative style?

No! Flet supports both the current imperative approach and the new declarative approach.

Where are the StateView and ControlBuilder controls?

They are gone! They were in-place prototypes for the broader declarative concept. Mixing declarative and imperative styles in the same app caused issues.

Do I need to call update() in a declarative app?

No! In a declarative app a component is the unit of update. Whenever a component's parameters or state change, it re-renders automatically.

How do I access the page instance?

Use ft.context:

print(ft.context.page.web)

How do I call a control method?

Use an ft.Ref to get a reference to a control:

@dataclass
class State:
txt_name: ft.Ref[ft.TextField] = field(default_factory=lambda: ft.Ref())

@ft.component
def App(state):
return ft.TextField(ref=state.txt_name)

How do I use a TextField or other input control?

The recommended approach is to use "controlled" inputs, where controls keep their state in the app's state:

from dataclasses import dataclass
from typing import cast

import flet as ft


@dataclass
@ft.observable
class Form:
name: str = ""

def set_name(self, value):
self.name = value

async def submit(self, e: ft.Event[ft.Button]):
e.page.show_dialog(
ft.AlertDialog(
title="Hello",
content=ft.Text(f"Hello, {self.name}!"),
)
)

async def reset(self):
self.name = ""


@ft.component
def App():
form, _ = ft.use_state(Form())

return [
ft.TextField(
label="Your name",
value=form.name,
on_change=lambda e: form.set_name(e.control.value),
),
ft.Row(
cast(
list[ft.Control],
[
ft.FilledButton("Submit", on_click=form.submit),
ft.FilledTonalButton("Reset", on_click=form.reset),
],
)
),
]


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

Here, the value of TextField is stored in state.name, and the on_change handler keeps it in sync.

Call to action

Try the new Flet declarative approach in the latest 0.70.0.dev releases and let us know what you think!

While we update the docs to cover declarative programming in more depth, we encourage you to check the React introduction and try the Tic-Tac-Toe tutorial. It's not Python, but the JavaScript is simple to follow.

We built a similar declarative Tic-Tac-Toe Flet app that you can compare with its React counterpart as you work through the tutorial.

The next stop is the Flet 1.0 Beta release. We're almost there — new docs (you can follow their progress here), more integration tests, and plenty of polish are underway.

Happy Fletting!