Understanding the Migration Landscape
Flet is a modern Python framework that wraps Flutter's rendering engine to deliver real-time, reactive user interfaces for desktop, web, and mobile from a single Python codebase. Legacy frameworks in this context refer to older Python UI toolkits such as Tkinter, wxPython, PyQt/PySide, Kivy, and even web-oriented libraries like Flask with Jinja templates or Dash. These frameworks have served developers well for years, but they often come with architectural constraints — imperative UI manipulation, manual state synchronization, platform-specific quirks, and dated visual aesthetics that require significant effort to modernize.
Migrating from a legacy framework to Flet means reimagining your application as a declarative, reactive component tree where the UI automatically rebuilds itself in response to state changes. Instead of manually calling label.config(text="new value") or setState patterns, you restructure your code so that Flet's control handlers and page updates handle the synchronization for you. The result is a cleaner separation of concerns, a single codebase that runs identically on multiple platforms, and access to Material Design 3 widgets out of the box.
Why Migration to Flet Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Cross-Platform Reach Without Extra Effort
Legacy frameworks often lock you into a specific platform. Tkinter struggles with mobile deployment. PyQt requires complex packaging per OS. Kivy supports mobile but has a steep learning curve for custom widgets. Flet gives you desktop (Windows, macOS, Linux), web (browser via WebAssembly or server-rendered), and mobile (PWA or native via Flutter embedding) from a single flet module. You write Python — Flet handles the Flutter layer underneath.
Reactive UI Eliminates Boilerplate
In Tkinter or PyQt, updating a label after a button click requires explicit references and mutation calls scattered across callbacks. Flet's reactive model means you simply reassign a control's property, and the framework schedules a re-render. This drastically reduces state-management bugs and callback spaghetti.
Modern Visual Language Instantly
Legacy toolkits often look dated on modern OS themes. Achieving a contemporary look in PyQt requires extensive stylesheets. Flet inherits Flutter's Material Design 3 library, giving you polished, adaptive widgets — buttons, cards, navigation rails, snackbars — with zero design effort.
Web Deployment as a First-Class Target
Deploying a PyQt or Tkinter app to the web is nearly impossible. Flet apps can run as web applications via flet.app() or be deployed to Fly.io, Replit, or any Python hosting service. This opens internal tools to browser-based access without rewriting the frontend in JavaScript.
Step-by-Step Migration Process
1. Audit Your Existing Application Structure
Before writing any Flet code, map out your legacy app's architecture. Identify:
- Windows/Dialogs: Each top-level window becomes a Flet
Pageor aView(for navigation). - Layouts: Grids, horizontal/vertical boxes in PyQt map to Flet's
Row,Column,Grid, andContainercontrols. - Data Display: Tables, lists, trees become
DataTable,ListView,GridView, orColumnwith repeated controls. - Event Handlers: Callbacks tied to button clicks, menu selections, or form submissions become Flet control event handlers like
on_click,on_change,on_submit. - State: Any mutable data that affects the UI — user sessions, form values, loaded records — should be centralized into a state class or dictionary that the
Pagecan reference.
2. Set Up Your Flet Project
Install Flet and create the entry point. A minimal Flet app looks like this:
import flet as ft
def main(page: ft.Page):
page.title = "Migrated App"
page.theme_mode = ft.ThemeMode.LIGHT
page.window.width = 800
page.window.height = 600
page.update()
ft.app(target=main)
This replaces your legacy framework's application bootstrap. For desktop deployment, ft.app() opens a native window. For web, switch to ft.app(target=main, view=ft.AppView.WEB_BROWSER).
3. Map Windows to Pages and Views
Suppose your legacy PyQt application has a MainWindow and a SettingsDialog. In Flet, the main window becomes the initial Page content, and the settings dialog becomes a modal alert dialog or a separate view if using navigation.
Legacy PyQt code:
# Legacy: PyQt main window with a button opening a dialog
from PyQt5.QtWidgets import QMainWindow, QPushButton, QDialog, QLabel
class SettingsDialog(QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("Settings")
label = QLabel("Configure options here")
self.layout().addWidget(label)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("My App")
button = QPushButton("Open Settings")
button.clicked.connect(self.open_settings)
self.setCentralWidget(button)
def open_settings(self):
dialog = SettingsDialog()
dialog.exec_()
Equivalent Flet code:
import flet as ft
def main(page: ft.Page):
page.title = "My App"
def open_settings(e):
# Replace QDialog with AlertDialog
settings_dlg = ft.AlertDialog(
title=ft.Text("Settings"),
content=ft.Text("Configure options here"),
actions=[ft.TextButton("Close", on_click=lambda e: close_dlg(e))],
)
page.dialog = settings_dlg
settings_dlg.open = True
page.update()
def close_dlg(e):
page.dialog.open = False
page.update()
page.add(ft.ElevatedButton("Open Settings", on_click=open_settings))
ft.app(target=main)
Notice how the imperative dialog construction becomes a declarative control assigned to page.dialog. The page.update() call triggers a re-render reflecting the open state.
4. Convert Layouts to Row, Column, and Container
Legacy layout managers translate directly to Flet's layout controls. Here is a comparison of a form layout:
Legacy Tkinter code:
# Legacy: Tkinter form with grid layout
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
root.title("Login")
ttk.Label(root, text="Username:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
username_entry = ttk.Entry(root)
username_entry.grid(row=0, column=1, padx=5, pady=5)
ttk.Label(root, text="Password:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
password_entry = ttk.Entry(root, show="*")
password_entry.grid(row=1, column=1, padx=5, pady=5)
login_btn = ttk.Button(root, text="Login")
login_btn.grid(row=2, column=1, sticky="e", padx=5, pady=10)
root.mainloop()
Equivalent Flet code:
import flet as ft
def main(page: ft.Page):
page.title = "Login"
page.padding = 20
page.spacing = 10
username_field = ft.TextField(label="Username", width=300)
password_field = ft.TextField(label="Password", password=True, can_reveal_password=True, width=300)
def login_click(e):
page.snack_bar = ft.SnackBar(ft.Text(f"Welcome, {username_field.value}!"))
page.snack_bar.open = True
page.update()
# Flet Column replaces Tkinter grid rows
page.add(
ft.Column([
ft.Row([
ft.Text("Username:"),
username_field,
]),
ft.Row([
ft.Text("Password:"),
password_field,
]),
ft.ElevatedButton("Login", on_click=login_click),
])
)
ft.app(target=main)
The imperative grid() calls with row/column indices are replaced by nesting Row and Column controls. This declarative structure is easier to read and modify.
5. Migrate Data Tables and Lists
Data-heavy applications often use QTableWidget or tkinter.Treeview. Flet provides DataTable with sortable columns and ListView for scrollable lists.
Legacy PyQt table example:
# Legacy: PyQt table populated from a list of dicts
from PyQt5.QtWidgets import QTableWidget, QTableWidgetItem
data = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
]
table = QTableWidget(len(data), 2)
table.setHorizontalHeaderLabels(["Name", "Email"])
for i, row in enumerate(data):
table.setItem(i, 0, QTableWidgetItem(row["name"]))
table.setItem(i, 1, QTableWidgetItem(row["email"]))
Equivalent Flet code:
import flet as ft
data = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
]
def build_data_table():
return ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Name")),
ft.DataColumn(ft.Text("Email")),
],
rows=[
ft.DataRow(cells=[
ft.DataCell(ft.Text(row["name"])),
ft.DataCell(ft.Text(row["email"])),
]) for row in data
],
)
Flet's DataTable handles scrolling, selection, and sorting natively. For very large datasets, use ListView with lazy loading via on_scroll events.
6. Replace Callbacks with Reactive Handlers
Legacy frameworks often use mutable references and imperative updates. Flet encourages you to centralize state and let controls reference it directly. Consider a counter application:
Legacy Tkinter:
# Legacy: Tkinter counter with manual label updates
counter = 0
def increment():
global counter
counter += 1
count_label.config(text=str(counter))
count_label = tk.Label(root, text="0")
count_label.pack()
tk.Button(root, text="+", command=increment).pack()
Flet reactive counter:
import flet as ft
class CounterState:
def __init__(self):
self.count = 0
def main(page: ft.Page):
state = CounterState()
def increment(e):
state.count += 1
count_text.value = str(state.count)
page.update()
count_text = ft.Text(value="0", size=32)
page.add(
ft.Column([
count_text,
ft.ElevatedButton("+", on_click=increment),
])
)
ft.app(target=main)
The key pattern: hold state in a plain Python object, and have event handlers modify that state then update the relevant control properties. For complex apps, consider a dedicated state class with refresh_ui methods.
7. Handle Navigation and Multi-Page Apps
Legacy apps with multiple windows or stacked widgets map to Flet's navigation system. Use page.views for a navigation stack or NavigationBar / NavigationRail for tab-based navigation.
Example: migrating a PyQt QStackedWidget to Flet views:
# Legacy: QStackedWidget with index-based switching
stack = QStackedWidget()
stack.addWidget(home_page_widget) # index 0
stack.addWidget(settings_page_widget) # index 1
# Switch: stack.setCurrentIndex(1)
Flet equivalent with page routing:
import flet as ft
def main(page: ft.Page):
page.title = "Multi-page App"
def route_change(e):
page.views.clear()
# Home view
page.views.append(
ft.View(
"/",
controls=[
ft.AppBar(title=ft.Text("Home")),
ft.ElevatedButton("Go to Settings",
on_click=lambda _: page.go("/settings")),
],
)
)
if page.route == "/settings":
page.views.append(
ft.View(
"/settings",
controls=[
ft.AppBar(title=ft.Text("Settings")),
ft.ElevatedButton("Back to Home",
on_click=lambda _: page.go("/")),
],
)
)
page.update()
page.on_route_change = route_change
page.go(page.route)
ft.app(target=main)
This gives you browser-style back/forward navigation. For desktop apps with a sidebar, combine NavigationRail with a content area that swaps controls based on the selected index.
Best Practices for a Smooth Migration
Start with a Vertical Slice
Don't attempt a full rewrite in one go. Pick one self-contained feature — a settings dialog, a report viewer, a login form — and migrate it end-to-end. This builds familiarity with Flet's patterns and reveals any architectural mismatches early. Once that slice works, expand incrementally.
Separate Business Logic from UI Early
Before migration, refactor your legacy code to extract business logic into plain Python functions or classes that don't import any UI framework. For example, move data validation, API calls, and calculations into a services/ module. Your Flet UI will call these same functions, ensuring behavior consistency and making the migration purely a UI-layer swap.
Use Async Handlers for Long Operations
Legacy frameworks often block the UI thread during heavy work unless you explicitly use threads. Flet controls accept async event handlers. Use async def for handlers that perform I/O, and wrap CPU-bound work with asyncio.to_thread or a ThreadPoolExecutor. Display a ProgressBar or SnackBar to keep the user informed during loading.
import flet as ft
import asyncio
async def load_data_click(e):
progress_ring.visible = True
page.update()
# Simulate async data fetch
await asyncio.sleep(2)
data = await fetch_from_api()
progress_ring.visible = False
result_list.controls = [ft.Text(item) for item in data]
page.update()
progress_ring = ft.ProgressRing(visible=False)
result_list = ft.Column()
Preserve Keyboard Shortcuts and Accessibility
Many legacy apps have rich keyboard navigation and accessibility features built over years. Flet supports on_keyboard_event on the page level and KeyboardEvent handling. Audit your legacy shortcuts and map them explicitly. For accessibility, Flet's Material widgets include semantic labels — set semantics_label on interactive controls.
Package Configuration and Theming Systematically
Instead of scattering page.theme and page.theme_mode calls, create a theme configuration dictionary at startup. This makes it easy to replicate your legacy app's color scheme and typography:
page.theme = ft.Theme(
color_scheme=ft.ColorScheme(
primary=ft.colors.INDIGO,
secondary=ft.colors.TEAL,
surface=ft.colors.GREY_50,
),
text_theme=ft.TextTheme(
body_large=ft.TextStyle(size=16),
),
)
page.theme_mode = ft.ThemeMode.SYSTEM
Test Across Target Platforms Early
A major benefit of Flet is cross-platform deployment. Don't wait until the end to test on web and mobile. Run your migrated slice with ft.app(target=main, view=ft.AppView.WEB_BROWSER) to see how it renders in a browser. Check touch interactions, responsive layout breakpoints, and navigation behavior. Desktop-only assumptions (like hover states or window resize) need adaptation for mobile.
Leverage Community Controls and Examples
Flet's gallery and community contribute reusable controls and patterns. Before building a complex widget from scratch (like a calendar picker or a rich text editor), check the official gallery and GitHub discussions. Migrating a legacy custom widget might be as simple as adopting an existing Flet community control with minor styling adjustments.
Common Migration Pitfalls and How to Avoid Them
Translating Imperative Layouts One-to-One
A common mistake is trying to replicate exact pixel positions from a legacy layout using Container with absolute positioning. Instead, embrace Flet's responsive layout model. Use Row and Column with expand, alignment, and spacing properties to achieve the same visual structure declaratively. Reserve absolute positioning only for overlay elements like floating action buttons.
Overusing page.update()
Calling page.update() after every tiny property change works but is inefficient. Batch updates: modify multiple controls, then call page.update() once. For controls inside a Column or Row, you can call control.update() on the specific container to minimize the re-render scope.
Ignoring the Event Loop in Web Mode
When running as a web app, Flet uses a server-client WebSocket connection. Blocking the Python event loop with synchronous sleep or CPU-heavy work will freeze the UI for all connected clients. Always use async handlers or offload work to background tasks.
Real-World Migration Example: A Todo List App
Let's walk through a complete migration of a small but realistic todo list application from Tkinter to Flet.
Legacy Tkinter todo app:
# Legacy Tkinter Todo App
import tkinter as tk
from tkinter import ttk, messagebox
class TodoApp:
def __init__(self, root):
self.root = root
self.root.title("Todo List")
self.tasks = []
# Input frame
frame = ttk.Frame(root, padding=10)
frame.pack(fill="x")
self.task_entry = ttk.Entry(frame, width=40)
self.task_entry.pack(side="left", padx=5)
ttk.Button(frame, text="Add", command=self.add_task).pack(side="left")
# Task list
self.task_listbox = tk.Listbox(root, height=15, width=60)
self.task_listbox.pack(padx=10, pady=10)
# Action buttons
btn_frame = ttk.Frame(root, padding=10)
btn_frame.pack(fill="x")
ttk.Button(btn_frame, text="Delete", command=self.delete_task).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Clear All", command=self.clear_tasks).pack(side="left", padx=5)
def add_task(self):
task = self.task_entry.get().strip()
if task:
self.tasks.append(task)
self.task_listbox.insert("end", task)
self.task_entry.delete(0, "end")
else:
messagebox.showwarning("Empty Task", "Please enter a task.")
def delete_task(self):
selection = self.task_listbox.curselection()
if selection:
index = selection[0]
self.task_listbox.delete(index)
del self.tasks[index]
def clear_tasks(self):
self.tasks.clear()
self.task_listbox.delete(0, "end")
if __name__ == "__main__":
root = tk.Tk()
app = TodoApp(root)
root.mainloop()
Migrated Flet todo app:
import flet as ft
class TodoState:
def __init__(self):
self.tasks = []
def main(page: ft.Page):
page.title = "Todo List"
page.padding = 20
page.window.width = 500
page.window.height = 600
state = TodoState()
# Reactive controls
task_input = ft.TextField(
hint_text="What needs to be done?",
expand=True,
autofocus=True,
)
task_list = ft.Column(spacing=5)
def refresh_task_list():
"""Rebuild the task list from state."""
task_list.controls.clear()
if not state.tasks:
task_list.controls.append(
ft.Text("No tasks yet. Add one above!", italic=True, color=ft.colors.GREY)
)
else:
for i, task in enumerate(state.tasks):
task_list.controls.append(
ft.Row([
ft.Checkbox(
label=task,
expand=True,
on_change=lambda e, idx=i: toggle_task(idx),
),
ft.IconButton(
icon=ft.icons.DELETE_OUTLINE,
tooltip="Delete task",
on_click=lambda e, idx=i: delete_task(idx),
),
])
)
page.update()
def add_task(e):
task_text = task_input.value.strip()
if task_text:
state.tasks.append(task_text)
task_input.value = ""
task_input.focus()
refresh_task_list()
else:
page.snack_bar = ft.SnackBar(
ft.Text("Please enter a task."),
bgcolor=ft.colors.RED_400,
)
page.snack_bar.open = True
page.update()
def delete_task(index):
del state.tasks[index]
refresh_task_list()
def toggle_task(index):
# Visual feedback: checkbox toggles itself, we just track completion
# In a full app, you'd update a 'completed' boolean in state
pass
def clear_all(e):
state.tasks.clear()
refresh_task_list()
# Layout
page.add(
ft.Column([
ft.Row([
task_input,
ft.ElevatedButton("Add", on_click=add_task),
]),
ft.Divider(height=10),
task_list,
ft.Divider(height=10),
ft.Row([
ft.OutlinedButton("Clear All", on_click=clear_all),
], alignment=ft.MainAxisAlignment.END),
])
)
# Initial render
refresh_task_list()
ft.app(target=main)
The migration transforms imperative listbox manipulation into a refresh_task_list function that rebuilds a Column of controls from canonical state. The SnackBar replaces messagebox for non-blocking notifications. Each task row now has a checkbox and a delete icon, providing a richer interaction model than a plain listbox.
Deployment After Migration
Once your app is migrated, Flet offers multiple deployment paths. For desktop, package it with PyInstaller or flet pack. For web, deploy to Fly.io, Heroku, or any Python-compatible cloud service. A sample Procfile for Fly.io deployment:
# Procfile
web: python -m uvicorn main:app --host 0.0.0.0 --port $PORT
With the appropriate main.py entry point using ft.app(), your migrated application becomes instantly accessible to anyone with a browser, eliminating the need for end-user installation.
Conclusion
Migrating from a legacy Python UI framework to Flet is fundamentally an architectural upgrade — moving from imperative, platform-bound UI construction to a declarative, reactive model that targets desktop, web, and mobile from a single codebase. The process requires auditing your existing layouts, extracting business logic, and systematically replacing window management, layout containers, data displays, and event handlers with their Flet equivalents. While the initial investment demands learning Flet's control hierarchy and reactive patterns, the payoff is substantial: drastically less UI boilerplate, modern Material Design aesthetics out of the box, and web deployment that expands your application's reach without maintaining a separate frontend stack. Start with a vertical slice, keep state centralized, leverage async handlers for I/O, and test cross-platform early. The resulting codebase will be more maintainable, more testable, and ready for the next decade of Python application development.