WidgetView, WidgetContext, and AnyView erasure
The registry is the only AnyView erasure point. WidgetView stays generic. WidgetContext is how a widget talks to state and actions.
The registry is the only AnyView erasure point. WidgetView stays generic. WidgetContext is how a widget talks to state and actions. That split is the whole trick of an open SwiftUI server driven UI stack: keep the widget type checked until the last possible moment, then erase once so a page of mixed types can live in one ForEach.
A closed Codable enum looks simpler on day one. Every tutorial that starts with switch widget.type pays for that simplicity later, when the backend adds a type the binary does not know, or when two product teams need different views for the same payload. Flow-UI refuses the enum. You write a typed view. You register it. The page renderer never names your type.
This article is the seam that how to implement SDUI on iOS pointed at and did not unpack. If you already added a custom widget, this is why the three steps (model, view, register) are enough and why a fourth step that wraps AnyView yourself is a mistake.
WidgetView stays generic on purpose#
WidgetView is a SwiftUI View with one associated payload and one required initializer. The protocol is @MainActor isolated because View is. Without that annotation the initializer requirement would be nonisolated while every conformance is main actor bound, and Swift 6 rejects the mismatch.
That is the entire contract. There is no makeBody, no layout hook, no extra lifecycle. Layout chrome (padding, background, corners) arrives on the widget envelope and the renderer applies it around your view. Envelope tap and long_press are attached the same way. Your view owns what is inside the payload.
Associated Content is the backend contract#
Content must conform to WidgetContent: a Decodable struct plus a static widgetType string. That string is the JSON type the backend sends. Agree it once. Do not invent a parallel enum in the app that has to stay in lockstep with the server.
The view initializer takes the decoded Content and a WidgetContext. Some starter widgets ignore the context because they are display only. Anything that taps, steps, or expands stores the context and uses it.
What you register#
Registration is one generic call. The type checker still sees OrderCardWidget and OrderCardContent together. You never pass an AnyView in.
That call is how a host widget becomes reachable from JSON, from a section, from a header, from a footer, and from nested containers such as the accordion. Adding a widget never edits framework source. The guide is Adding a widget.
WidgetContext is the talking pipe#
Everything a widget view receives besides its typed content lives on WidgetContext. The struct is Sendable. It carries:
widgetID, the stable id for this instance on the pageactions, the map of event name toActionDatafrom the envelopestate, the page scopedWidgetStateStorefor ephemeral values- a private
dispatchActionclosure that is@MainActorand@Sendable
Widgets do not hold a PageStore. They do not import your router. They do not know whether a tap will toast, mutate, or open a sheet. They fire an event or an explicit ActionData and stop.
Dispatch by event name#
dispatch(event:) looks up the backend declared action for that name and, if one exists, forwards it. Missing events are a no-op. That is how a widget stays passive when the payload omitted actions. Standard names are tap, long_press, and change. Anything else the backend sent is still reachable through the same subscript.
Envelope level tap and long_press are wired by the renderer, so a display widget that only needs a row tap does not call dispatch itself. Controls inside the payload (a button, a stepper, an accordion header) call it explicitly.
Dispatch an ActionData you already have#
Buttons and icons often embed ActionData in the payload rather than under envelope events. dispatch(_ action: ActionData?) is the other entry. A nil action is ignored. The source widget id still travels with the call, which is what makes later mutations and debug logs attributable.
Inert context for previews and tests#
WidgetContext.inert(widgetID:actions:) builds a context whose dispatch goes nowhere. Previews and widget unit tests should use it. You get a real WidgetStateStore so steppers and expansion still bind, and you do not need a live PageStore or dispatcher to render a single widget.
The registry is the only AnyView wall#
A page is a heterogeneous list. SwiftUI ForEach over any View is not a thing you want to invent by hand. Somewhere, types have to collapse.
Flow-UI collapses them in one place: WidgetRegistry. register is generic and fully type checked. Inside that generic scope the registry stores two closures created together: one that decodes V.Content from the data key, and one that casts any WidgetContent back to V.Content and wraps V(content:context:) in AnyView. The internal cast cannot fail if registration stayed consistent, because the decode path and the view path were born in the same generic function.
That is the only widget view erasure. Widget authors do not return AnyView. Hosts do not type-erase before register. The page renderer asks the registry for a view and applies layout and gestures to whatever comes back.
Why erasure does not belong in the widget#
If every widget returned AnyView, you would lose the generic registration invariant. A mismatched payload would fail at runtime in the widget instead of being impossible to register. You would also copy the same AnyView(...) line into every product widget, which is how teams accidentally grow a second, informal registry.
Keep widgets generic. Let the table erase.
What happens when the type is unknown#
If the JSON type has no entry, decode does not throw. The widget becomes UnknownWidgetContent and diagnostics record unknownType. Rendering then follows UnknownWidgetPolicy: a labelled placeholder in debug, a skip in release. That policy lives on the registry, next to the same table that produces views. Unknown handling is not a second switch in your app.
The architecture walk is in Architecture. The iOS checklist that assumes this seam is in How to implement SDUI on iOS.
What the host should store on context, and what it should not#
WidgetContext.state is for values that are not in the JSON: stepper counts, expanded flags, input drafts. Server data stays on Content. If you write the current count back into a mutable payload model, a refresh and a user gesture will fight.
widgetID keys that state. Send real ids from the backend for anything interactive. Positional fallbacks exist so ForEach does not reshuffle on refresh, but they are the floor. Two widgets that share an id get renamed (id#2) and reported, because both SwiftUI identity and later mutations become ambiguous.
Previews without a page#
A #Preview that builds OrderCardWidget(content:context: .inert()) is enough to see the view. To preview nested children (an accordion that contains image_text_card), install a registry in the environment with .environment(\.flowRegistry, ...) and register the child types the nested payload needs. The accordion does not special case those types. It asks the same table the page uses.
Swift 6 isolation is not optional#
Do not mark your widget nonisolated to “make it easier to test.” The protocol is @MainActor because the view body is. Decode happens off the main actor in PageStore; view creation happens on it. That split is already done for you. Tests that decode PageModel do not need a widget view at all. Tests that instantiate a view need the main actor, which SwiftUI previews already are.
Common mistakes that recreate the enum#
Wrapping your widget body in AnyView “for the registry” does nothing useful and hides type errors.
Storing ActionDispatcher on the widget recreates a router inside the view. Use context.dispatch. The relay on the page forwards into the dispatcher with page context. Widgets stay reusable in sheets and previews.
Switching on widget.type in a host view to pick a SwiftUI tree means you did not register. The page renderer will not see that switch. Nested containers will not either. The open table only works if every type that can appear in JSON went through register.
Holding a reference to PageStore in a widget to “just refresh” skips refresh_page and the loader seam. If the backend wanted a reload, it sends an action. If you wanted a local reload, the host still owns that, not the widget.
Optional skeletons#
WidgetSkeletonProviding is a separate protocol. If your view type conforms, the registry stores a thunk that calls skeleton() and returns AnyView. Loading states and catalogs can then mirror the widget’s real shape instead of a generic block. This is the other erasure, and it is still inside the registry, still created at registration time, still not something widget bodies return.
If you do not conform, skeleton(forType:) falls back to a default block. Starter widgets that have a distinctive shape (title block, image text row, banner) provide skeletons. Display-only dividers do not need to.
Where this fits on a page#
A loaded page is header widgets, section widgets, footer widgets. Each row is WidgetRowView, which builds a WidgetContext from the widget id, envelope actions, the page WidgetStateStore, and the FlowActionRelay. It then asks the registry for a view. You never call view(for:context:) from product code unless you are writing another container widget, and even then you usually embed WidgetRowView so layout, gestures, and unknown handling stay consistent.
That is why custom widgets work inside the accordion with zero extra work: the accordion iterates children as AnyWidget and renders WidgetRowView. Same registry. Same context shape. Same erasure point.
Native SwiftUI still matters here. The widget is a real view, not a web blob. Accessibility, Dynamic Type, and dark mode come from the platform, which is the point of what native means in an SDUI stack. Server driven UI is JSON in, native views out, as SDUI on iOS argued. The registry is how those native views stay open without a giant switch.
Read next#
Write a typed WidgetView. Give it a WidgetContext. Register the type. Let the table be the only place AnyView appears for widgets.
Related documentation
Guides
Adding a widget
Teach Flow-UI a new server driven UI component in three steps, entirely from your own app: model the payload, write the SwiftUI view, register it.
Concepts
Architecture
How Flow-UI turns a JSON page into native SwiftUI: decode through an open registry, contain failures, render widgets, and dispatch actions as data.
Related articles
- Components6 min read
Overriding a starter widget
Last registration wins. Override a starter widget by registering the same type string with your view. No fork of the package.
- Components7 min read
Nested widgets and accordion
Nesting is a widget that holds more widgets. Accordion is the starter example: decode children through the same registry.
- Components8 min read
Starter widgets as worked examples
Starter widgets are examples you can override: title block, image text card, banner, tag rail, stepper, accordion, separator, button row.
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