Skip to main content

Client actions

Some things a browser can do are only allowed while it is handling a click or a key press: opening a file picker, writing to the clipboard, showing a share sheet, opening a new tab. The permission lasts for that one gesture and no longer.

That is a problem for the usual Flet pattern. When you call FilePicker.pick_files() from an on_click handler, the click travels to your Python code, your code runs, and the instruction to open the dialog travels back - by which point the browser no longer considers a gesture to be in progress and quietly refuses.

Safari enforces this strictly, Chrome and Firefox are lenient about it, so the symptom is confusing: the same app works on Android and on the desktop, and silently does nothing on an iPhone or iPad. Nothing is logged, because from the browser's point of view nothing went wrong.

Client actions close that gap. An action is attached to a control instead of being called from a handler, so the client already knows what to do when the tap arrives and can do it immediately, inside the gesture:

ft.Button("Upload", action=ft.PickFiles(file_picker, allow_multiple=True))

Opening a URL

OpenUrl opens a link. Opening a new tab is the part that browsers guard, since that is what a popup blocker exists to stop.

play_arrowTry Online
import flet as ft


def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click,
# so opening a new tab is not treated as an unsolicited popup. Compare with
# `UrlLauncher().launch_url()`, which has to reach Python first and is
# therefore blocked by Safari on iOS.
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text("Both buttons open the same page:"),
ft.Button(
"Open in this tab",
action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.SELF),
),
ft.Button(
"Open in a new tab",
action=ft.OpenUrl(
"https://flet.dev",
target=ft.UrlTarget.BLANK,
),
),
ft.Text(
"An action can be combined with on_click - the action "
"runs on the client, then your handler runs in Python."
),
ft.Button(
"Open and log",
action=ft.OpenUrl("https://flet.dev/docs"),
on_click=lambda e: page.show_dialog(
ft.SnackBar(ft.Text("Docs opened"))
),
),
],
),
)
)


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

The url property that controls have always had works the same way and is unchanged - OpenUrl is for when you want to combine it with other actions, or keep every gesture-gated operation written the same way.

Copying to the clipboard

play_arrowTry Online
import flet as ft


def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click.
# That is the only moment Safari lets a page write to the clipboard, which
# is why `Clipboard().set()` - which has to reach Python first - does
# nothing on iOS.
token = "flet-1234-5678"

def handle_copied(e):
page.show_dialog(ft.SnackBar(ft.Text("Copied to clipboard")))

page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text(f"Token: {token}", selectable=True),
ft.Button(
"Copy token",
icon=ft.Icons.CONTENT_COPY,
action=ft.CopyToClipboard(token),
on_click=handle_copied,
),
ft.Divider(),
ft.Text(
"An action's arguments are fixed before the click, so "
"to copy something typed just now, update the action "
"as the text changes."
),
note := ft.TextField(
label="Note",
value="Edit me, then copy",
on_change=lambda e: setattr(
copy_note, "action", ft.CopyToClipboard(note.value)
),
),
copy_note := ft.Button(
"Copy note",
icon=ft.Icons.CONTENT_COPY,
action=ft.CopyToClipboard("Edit me, then copy"),
on_click=handle_copied,
),
],
),
)
)


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

Sharing

play_arrowTry Online
import flet as ft


def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click.
# Browsers only open the share sheet during a gesture, so `Share()` called
# from Python has no effect on the web.
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text("Share this page with someone:"),
ft.Button(
"Share",
icon=ft.Icons.SHARE,
action=ft.ShareText(
"Flet lets you build multi-platform apps in Python: "
"https://flet.dev",
subject="Flet",
),
),
ft.Text(
"The share sheet is a system dialog - what it offers "
"depends on the platform, and on desktop browsers it "
"may not be available at all."
),
],
),
)
)


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

Picking files

PickFiles is the action that fixes file picking in a web app on iOS.

Because the dialog opens before your code sees the click, the selection cannot be returned to a caller the way pick_files() returns it. It arrives at FilePicker.on_result instead. The picked files stay associated with the FilePicker, so upload() works exactly as before.

play_arrowTry Online
#
# Picking and uploading files in a way that also works in a web app on iOS.
#
# Run this example with:
# export FLET_SECRET_KEY=<some_secret_key>
# uv run flet run --web examples/services/file_picker/pick_files_action/main.py
#
from dataclasses import dataclass, field

import flet as ft


@dataclass
class State:
picked_files: list[ft.FilePickerFile] = field(default_factory=list)


state = State()


def main(page: ft.Page):
prog_bars: dict[str, ft.ProgressRing] = {}

def handle_upload_progress(e: ft.FilePickerUploadEvent):
prog_bars[e.file_name].value = e.progress

def handle_result(e: ft.FilePickerResultEvent):
# A PickFiles action opens the dialog before Python sees the click, so
# the selection arrives here instead of being returned to a caller.
state.picked_files = e.files

prog_bars.clear()
upload_progress.controls.clear()
for f in e.files:
prog = ft.ProgressRing(value=0, bgcolor="#eeeeee", width=20, height=20)
prog_bars[f.name] = prog
upload_progress.controls.append(
ft.Row([prog, ft.Text(f"{f.name} ({f.size} bytes)")])
)
upload_button.disabled = len(e.files) == 0

async def handle_file_upload(e: ft.Event[ft.Button]):
upload_button.disabled = True
# The picked files stay on the FilePicker, so upload() takes them as-is.
await file_picker.upload(
files=[
ft.FilePickerUploadFile(
name=file.name,
upload_url=page.get_upload_url(f"dir/{file.name}", 60),
)
for file in state.picked_files
]
)

file_picker = ft.FilePicker(
on_result=handle_result,
on_upload=handle_upload_progress,
)
page.services.append(file_picker)

page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Button(
content="Select files...",
icon=ft.Icons.FOLDER_OPEN,
# Attaching the pick to the control - rather than
# calling file_picker.pick_files() from an on_click
# handler - is what makes the dialog open on iOS.
action=ft.PickFiles(file_picker, allow_multiple=True),
),
upload_progress := ft.Column(),
upload_button := ft.Button(
content="Upload",
icon=ft.Icons.UPLOAD,
on_click=handle_file_upload,
disabled=True,
),
],
),
)
)


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

What each action maps to

ActionEquivalent method
OpenUrlUrlLauncher.launch_url()
CopyToClipboardClipboard.set()
ShareTextShare.share_text()
PickFilesFilePicker.pick_files()

An action runs first, then your on_click handler is called as usual - so you can still react to the click in Python.

A control accepts a list as well as a single action, if you need more than one:

ft.Button(
"Copy and open",
action=[ft.CopyToClipboard(link), ft.OpenUrl(link, target=ft.UrlTarget.BLANK)],
)

Limits

An action's arguments are fixed before the click. This follows from what an action is - the client has to know the whole operation in advance, because there is no time to ask. To copy or share a value that changes, update the action when the value changes, as the clipboard example above does. There is no way around this; it is the browser's rule, not Flet's.

Reading the clipboard still prompts. Safari shows a paste-confirmation UI for Clipboard.get() whatever you do. An action makes the read possible, not invisible.

Outside the browser this does not apply. On desktop, Android and iOS apps there is no such restriction, and the ordinary method calls work fine. Actions work everywhere, so you can use them unconditionally if your app also runs on the web - but there is nothing to fix if it does not.