Server driven navigation with actions
Taps carry declarative actions: toast, sheet, api, deeplink, dismiss, refresh. The dispatcher chains host handlers.
Taps carry declarative actions: toast, sheet, api, deeplink, dismiss, refresh. The dispatcher chains host handlers. Flow-UI does not ship a router. Navigation is data plus a chain of ActionHandler values. Built-in types cover in-page effects. Anything that pushes a route, switches a tab, or opens an external URL is a host type you register yourself.
This article folds toast, dismiss, and refresh_page into one place so they do not need separate posts. The contracts live in Actions (concept) and Actions (API). The type itself is an open string on ActionData.
If you still think in NavigationLink trees compiled into each widget, the how to implement SDUI on iOS checklist already told you to stop. Widgets call context.dispatch. They do not know the destination.
ActionData is an open type plus the raw object#
ActionData.type is a free string agreed with the backend. The full JSON object is preserved as raw so the handler that understands the type can decode its own payload. Encoding writes raw back out. payload(T.self) is the convenience for a Decodable struct.
There is no package enum of allowed types. Adding deeplink or open_settings does not require a Flow-UI release. It requires a handler that returns true for that string.
WidgetActions maps event names to ActionData. Standard events are tap, long_press, and change. Envelope tap and long_press are attached by WidgetRowView. Named events are fired by widgets (a stepper’s change, a button inside data).
If the backend omitted an event, dispatch(event:) is a no-op. Widgets stay passive when the payload has no actions. That is how a display-only title block never becomes a control by accident.
What widgets must not do#
A widget that imports your coordinator and pushes a SwiftUI destination has compiled the route into the binary. The backend cannot swap that tap for a toast next week. The same widget cannot run inside a sheet without dragging the coordinator in. Dispatch the ActionData. Let the chain decide.
A widget that calls openURL itself skips debug logs, skips override, and skips cancellation. Two widgets will copy two parsers. One handler is cheaper.
The dispatcher is a chain, newest first#
ActionDispatcher ships with built-in handlers when builtIns is true (the default). The types live in BuiltInHandlers.swift. register appends. route walks handlers.reversed() and stops at the first true. A host handler registered later can override a built-in for the same type.
ActionContext gives the source widget id, a weak PageStore, a FlowPresenter, and redispatch for composite unwrapping. FlowActionRelay is the thin pipe from widget views into dispatch. FlowPageView installs a relay that builds that context. Previews use the inert relay that swallows everything.
Deeplinks are deliberately absent from the built-in list. The comment on ActionDispatcher says navigation belongs to the host. Register a handler and forward the URL into the navigation you already have. The package will not grow a router later to “make deeplinks easier.” That would pick NavigationStack for every app, which is false.
In-flight actions are Task values the dispatcher retains so cancelInFlight() can stop them when the page tears down. An api round trip must not land on a page that is gone.
Debug builds append ActionLogEntry (type, source widget id, handler name). Unhandled types record handledBy: nil. That is how you find toasts instead of toast, or a host type you forgot to register. Do not crash on unhandled. Log it.
Override without disabling the chain#
Handlers are newest first. The first true consumes the action. An override of toast must present (or explicitly decide not to) before returning true. If you return true and forget to present, the built-in never runs and the user sees nothing.
A host handler that returns true for every type will disable toast, dismiss, and refresh. Guard on action.type. Return false when the type is not yours.
toast, dismiss, and refresh_page#
These three are the chrome most pages need without a custom handler. Learn them before you write DeeplinkHandler.
toast#
JSON: { "type": "toast", "message": "Saved", "duration": 2.5 }. message is required. duration defaults to 2.5 seconds. The handler claims the action even when the payload is unusable, so a malformed toast is not logged as “unhandled.” In debug, a bad payload hits assertionFailure with the raw object. FlowPresenter.show displays ToastView and clears it after the duration.
Use toast for confirmation copy, not as a substitute for a route. If the backend wants the user to move, send a host type or an inline sheet.
dismiss#
JSON: { "type": "dismiss" }. FlowPresenter.dismiss() runs. If the host set onDismiss, that closure runs. Else if a sheet is active, the sheet is cleared. Else dismissRequested becomes true so a host embedding the page in its own navigation can pop. Inside a sheet, a footer Done button is this action. There is no package router to pop. The presenter signals. The host pops.
A widget that calls dismiss on a root page without a sheet is asking the host to leave. Wire dismissRequested (or onDismiss) in that host. If you ignore the flag, the tap will appear to do nothing. UIKit hosts that pushed FlowHostingController should observe the same signal and popViewController.
refresh_page#
JSON: { "type": "refresh_page" }. The handler awaits pageStore?.refresh(). Refresh keeps current content visible while the loader round trip runs, resets WidgetStateStore, and uses PageRequest.Kind.refresh. Pagination failure is a different path: a failed loadNextPage does not blank a healthy page. Refresh goes through the shared fetch helper; a failed refresh can still enter the failed state. Pull to refresh, if the envelope set refresh.pull_to_refresh, calls the same refresh() from the scroll view.
Use refresh_page when the backend knows the document is stale after a tap (a submit that changed more than one widget). Use an api action when one widget can be replaced in place. Do not reload the whole page to toggle a favourite.
Sheets and api stay built-in, not routes#
open_bottom_sheet expects a sheet object, decodes SheetModel with the page registry, and presents FlowSheetView. Native SwiftUI, not a web modal. Sheets that need their own network round trip are a host handler: fetch, then redispatch with an inline payload. The field list is Bottom sheets.
api sends the whole action object through PageLoader as PageRequest.Kind.action and applies returned mutations. Flow-UI still performs no HTTP of its own. The host loader does. Mutation kinds are documented in Mutations: replace_widget, remove_widget, append_sections, prepend_sections, replace_page, plus an optional toast on the response.
Neither built-in is a navigation router. A sheet is presentation on the current page. An api action is a round trip that mutates the current page. Pushing a different page id is host navigation: your handler loads another PageStore, or your existing coordinator pushes a FlowPageView.
| Type | Built in | Typical use |
|---|---|---|
toast | yes | Confirmation copy |
dismiss | yes | Close sheet, or ask the host to pop |
refresh_page | yes | Reload the current document |
open_bottom_sheet | yes | Inline SheetModel |
api | yes | Loader round trip plus mutations |
deeplink | no | Host handler, your navigation |
How a tap actually moves#
- The user taps.
WidgetRowViewor an inner control callscontext.dispatch. WidgetContextresolves envelope events or the explicitActionData.FlowActionRelayforwards intoActionDispatcher.dispatchwith page context.- Handlers run newest first until one returns
true. - Debug builds append a log row.
If nothing returns true, the log records handledBy: nil. That is a missing handler or a typo in type, not a reason to fail the page.
Backend habits that keep actions boring#
Send the smallest type that does the job. Toast for copy. Dismiss for close. Refresh for a full document. Sheet for extra widgets on this page. api for in-place mutation. Host type for a different page, a tab, or Safari.
Keep type stable. Do not send show_toast in one endpoint and toast in another.
Put extra keys next to type. Handlers decode raw. Do not hide a URL inside message.
Standardise event names on tap, long_press, and change before inventing appear. The renderer will not fire appear unless a widget calls it.
Where this sits#
Stable widget identities matter because api mutations address ids. How to implement SDUI on iOS listed dispatch as a seam. Native taps on native widgets are still native, which is the point of SDUI on iOS: native SwiftUI, not WebViews. The architecture post in this series names the same dispatcher as a host-owned chain: server driven UI architecture.
Open type string. Built-in in-page effects. Host handlers for real navigation. No router in the package.
Read next#
Declarative actions on the payload. A handler chain on the client. The host still owns destinations.
Related documentation
Related articles
- Schema6 min read
Stable widget identities
SwiftUI diffs identity. SDUI widgets need stable ids across refresh so steppers and expansion state do not reset.
- Production6 min read
Unknown widgets and schema versioning
Old binaries will see new type strings. Version the contract additively. Unknown widgets must fallback. Optional: host sends a capability header.
- Schema6 min read
Lossy decoding and diagnostics
Unknown widget types and malformed payloads are dropped with a key path. The rest of the page still renders.
More on the blog.
Read the source
Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.
GitHubGet started with Flow-UI
Install the Swift package, register a widget, and render a page from JSON.
Get started