Simple, intuitive routing with automatic view stacking for Flet applications.
Version 0.3.0 brings a major simplification to the API:
- 🎯 Even simpler routing - Views are now just functions that return
ft.Viewobjects - 🔄 Stack navigation - Use
+/routeto stack views,/routeto replace the entire stack - ❌ No more
@ft.component- Just simple functions, no decorator boilerplate - ⚡ Automatic reactivity - State changes trigger re-renders automatically
- đź§ą Cleaner code - Less boilerplate, more straightforward
See the migration guide if upgrading from 0.2.x
- 🎯 Decorator-based routing - Clean
@route()decorator for route definitions - 📚 Stack navigation - Intuitive stack vs replace navigation with "+" prefix
- 🔄 Observable state management - Built-in state with
@ft.observabledataclasses - ⚡ Async support - Handle async data loading with automatic loading indicators
- 🎨 URL parameters - Extract parameters from routes like
/user/{id} - 🚀 Simple setup - Just call
page.render_views(FletStack)in your app - đź”— No boilerplate - Views are simple functions returning
ft.Viewobjects
- Python 3.9+
- Flet >= 0.70.0.dev6281
pip install flet-stackpip install git+https://github.com/fasilwdr/flet-stack.gitpip install git+https://github.com/fasilwdr/flet-stack.git@v0.3.0git clone https://github.com/fasilwdr/flet-stack.git
cd flet-stack
pip install .import flet as ft
from flet_stack import route, FletStack
import asyncio
# Define your routes with the @route decorator
@route("/")
def home_view():
return ft.View(
appbar=ft.AppBar(title=ft.Text("Home")),
controls=[
ft.Text("Home Page", size=30),
ft.Button(
"Go to Profile",
on_click=lambda _: asyncio.create_task(
ft.context.page.push_route("+/profile")
)
),
]
)
@route("/profile")
def profile_view():
return ft.View(
appbar=ft.AppBar(title=ft.Text("Profile")),
controls=[
ft.Text("Profile Page", size=30),
]
)
# Run your app
ft.run(lambda page: page.render_views(FletStack))That's it! Clean, simple routing with no boilerplate.
flet-stack supports two navigation modes:
Use the "+" prefix to add a view on top of the current stack:
# Adds /profile on top of the current view
asyncio.create_task(ft.context.page.push_route("+/profile"))
# User can press back to return to previous viewUse no prefix to replace the entire navigation stack:
# Replaces entire stack with just /home
asyncio.create_task(ft.context.page.push_route("/home"))
# Previous views are cleared - back button goes to previous view in new stackCommon Pattern:
# From home, stack other views
ft.Button("Products", on_click=lambda _: push_route("+/products"))
# From anywhere, return home (clearing stack)
ft.IconButton(icon=ft.Icons.HOME, on_click=lambda _: push_route("/"))Extract parameters from your routes:
@route("/user/{user_id}")
def user_view(user_id):
return ft.View(
appbar=ft.AppBar(title=ft.Text(f"User {user_id}")),
controls=[
ft.Text(f"User Profile: {user_id}", size=30),
]
)Use observable dataclasses to manage component state. State automatically triggers re-renders when methods are called:
from dataclasses import dataclass
@ft.observable
@dataclass
class CounterState:
count: int = 0
def increment(self, e):
self.count += 1
def decrement(self, e):
self.count -= 1
@route("/counter", state_class=CounterState)
def counter_view(state):
return ft.View(
appbar=ft.AppBar(title=ft.Text("Counter")),
controls=[
ft.Text(f"Count: {state.count}", size=30),
ft.Row([
ft.Button("Decrement", on_click=state.decrement),
ft.Button("Increment", on_click=state.increment),
]),
]
)State automatically triggers re-renders when you call methods like increment() or decrement() - no manual update needed!
Load data asynchronously before showing your view:
@ft.observable
@dataclass
class UserState:
user_data: dict = None
async def load_user_data(state, user_id):
# Simulate API call
await asyncio.sleep(1)
state.user_data = {
"id": user_id,
"name": f"User {user_id}",
"email": f"user{user_id}@example.com"
}
@route("/user/{user_id}", state_class=UserState, on_load=load_user_data)
def user_detail_view(state, user_id):
return ft.View(
appbar=ft.AppBar(title=ft.Text(state.user_data['name'])),
controls=[
ft.Text(f"Name: {state.user_data['name']}", size=20),
ft.Text(f"Email: {state.user_data['email']}", size=16),
ft.Text(f"ID: {state.user_data['id']}", size=16),
]
)While on_load executes, a loading spinner is automatically displayed.
You can also use synchronous loading functions:
def load_item_info(state, category, item_id):
"""Sync data loading"""
state.info = {
"category": category.capitalize(),
"item_id": item_id,
"name": f"{category.capitalize()} Item #{item_id}",
"price": f"${int(item_id) * 10}.99"
}
@route(
"/category/{category}/item/{item_id}",
state_class=ItemState,
on_load=load_item_info
)
def item_view(state, category, item_id):
return ft.View(
appbar=ft.AppBar(title=ft.Text(state.info['name'])),
controls=[
ft.Text(f"{state.info['name']}", size=20),
ft.Text(f"Price: {state.info['price']}", size=18),
]
)Handle routes with multiple parameters:
@route("/category/{category}/item/{item_id}")
def item_view(category, item_id):
return ft.View(
appbar=ft.AppBar(title=ft.Text(f"{category} Items")),
controls=[
ft.Text(f"Category: {category}", size=20),
ft.Text(f"Item ID: {item_id}", size=20),
]
)You can start your app at any route instead of the default /:
def main(page: ft.Page):
page.title = "My App"
page.route = "/login" # Start at login page
page.render_views(FletStack)
ft.run(main)@route(path: str, state_class: Type = None, on_load: Optional[Callable] = None)- path: The route path for this view (e.g.,
/,/user/{user_id}) - state_class: Optional dataclass decorated with
@ft.observablefor state management - on_load: Optional function to call before rendering (can be async)
- Parameters are automatically injected based on function signature
- Can accept:
state(if state_class provided) and any URL parameters - While executing, a loading view is displayed automatically
Main component that manages the routing stack and renders views.
# Option 1: Direct render
ft.run(lambda page: page.render_views(FletStack))
# Option 2: In main function
def main(page: ft.Page):
page.title = "My App"
page.route = "/login" # Optional: Set initial route
page.render_views(FletStack)
ft.run(main)Use asyncio.create_task with ft.context.page.push_route:
# Stack navigation - add to current stack
asyncio.create_task(ft.context.page.push_route("+/profile"))
# Replace navigation - replace entire stack
asyncio.create_task(ft.context.page.push_route("/"))
# In button click handler
ft.Button(
"Go to Profile",
on_click=lambda _: asyncio.create_task(
ft.context.page.push_route("+/profile")
)
)Check the examples/ directory for more detailed examples:
basic_example.py- Simple routing and navigationadvanced_example.py- State management, async loading, and URL parameters
flet-stack provides a FletStack component that:
- Registers all
@routedecorated functions - Manages a navigation stack with stack vs replace modes
- Handles state management with observable dataclasses and automatic re-renders
- Manages async/sync loading with automatic progress indicators
- Renders views with proper navigation support
- Supports custom initial routes via
page.route - Isolates state per route instance for parameterized routes
If you're upgrading from version 0.2.x, here are the key changes:
# Before (0.2.x)
from flet_stack import view
# After (0.3.0)
from flet_stack import routeViews no longer return lists of controls wrapped in @ft.component. They now return ft.View objects directly:
# Before (0.2.x)
@view("/profile", appbar=ft.AppBar())
@ft.component
def profile_view():
return [
ft.Text("Profile"),
ft.Button("Click me")
]
# After (0.3.0)
@route("/profile")
def profile_view():
return ft.View(
appbar=ft.AppBar(),
controls=[
ft.Text("Profile"),
ft.Button("Click me")
]
)Use the "+" prefix for stacking views:
# Before (0.2.x) - always stacked
asyncio.create_task(ft.context.page.push_route("/products"))
# After (0.3.0) - explicit stack vs replace
asyncio.create_task(ft.context.page.push_route("+/products")) # Stack
asyncio.create_task(ft.context.page.push_route("/")) # Replaceon_load no longer accepts page or view parameters:
# Before (0.2.x)
async def load_user(state, view, user_id):
state.user = fetch_user(user_id)
view.appbar = ft.AppBar(title=ft.Text(state.user['name']))
# After (0.3.0)
async def load_user(state, user_id):
state.user = fetch_user(user_id)
# Set appbar directly in view functionSimply remove the @ft.component decorator:
# Before (0.2.x)
@view("/counter", state_class=CounterState)
@ft.component
def counter_view(state):
return [ft.Text(f"Count: {state.count}")]
# After (0.3.0)
@route("/counter", state_class=CounterState)
def counter_view(state):
return ft.View(
controls=[ft.Text(f"Count: {state.count}")]
)Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.
Built on top of the amazing Flet framework by Feodor Fitsner.
If you encounter any issues or have questions:
- Open an issue on GitHub
- Check the examples directory
- Read the Flet documentation