Skip to content
Flow-UILive
On this page

Observation, PageStore, and SwiftUI state

PageStore owns the decoded page. WidgetStateStore owns per-widget state. SwiftUI Observation is how the view tree stays in sync.

Ayush MishraPublished 6 min read

PageStore owns the decoded page. WidgetStateStore owns per-widget state. SwiftUI Observation is how the view tree stays in sync. Mixing those two stores is the usual bug: putting a stepper count on PageModel, or putting the loaded document in @State inside a row. This article splits them the way the package does.

It follows dynamic pages from a backend and the iOS 17 render path. The state concept is the short version. Here is the ownership model with refresh, pagination, and mutations in view.

The Flow-UI mark: two nodes joined through a filled centre

PageStore is document state#

PageStore is @Observable and @MainActor. Public surface: state, isLoadingMore, pageID, registry, stateStore, diagnostics, plus page as a convenience when state is .loaded. It does not expose a mutable PageModel for the view to poke. Views observe. Mutations go through apply(_ mutation: PageMutation) or through load methods.

The state machine#

loading is first paint and retry via loadInitial(). loaded(PageModel) is a document with at least one widget across header, sections, and footer. empty is a successful decode with no widgets. failed(message:) is a loader or envelope decode error. Content problems inside widgets do not fail the store. They become diagnostics and placeholder content.

loadInitial() assigns .loading then fetches .initial. refresh() keeps current content, calls stateStore.reset(), fetches .refresh. retry() is loadInitial(). loadNextPage() requires .loaded, pagination.hasMore, and not already loading more. Failure there does not assign .failed. The page stays. That is document state refusing to lie because a cursor request 500'd.

StoreLifetime.swiftswift
let store = PageStore(pageID: "cart", loader: loader, registry: registry)
await store.loadInitial()
await store.refresh()
await store.loadNextPage()

Decode runs in a detached task. Diagnostics are replaced as a batch after success, so the overlay does not show a mix of two responses. Fetch cancellation is the other Observation-friendly detail: a superseded load does not publish.

WidgetStateStore is ephemeral state#

Server data is immutable once decoded. A stepper count, an accordion expanded flag, a draft key: those are not in the JSON after first paint unless you round-trip an api action. WidgetStateStore is @Observable, @MainActor, keyed by widget id plus a name the widget chooses.

Why not @State on the widget#

@State dies when a LazyVStack recycles the view. Scroll away, scroll back, the count is gone. The store lives on the page, installed in the environment as flowStateStore, handed to WidgetContext.state. Bindings come from binding(widgetID:key:default:).

swift
private var expanded: Binding<Bool> {
    context.state.binding(
        widgetID: context.widgetID,
        key: "expanded",
        default: false
    )
}

reset() clears everything. Refresh calls it because new content should not inherit yesterday's stepper. Pagination does not call it, because appending sections must not wipe the accordion the user opened on page one. Those two sentences are the whole lifecycle. They are easy to invert in a custom store. Do not invert them.

Ids must be stable for this to work. Backend id, or prefix@codingPath, with duplicates renamed id#2. Nested accordion children are not unique page-wide. Their state keys still use whatever id they decoded. Send real ids on interactive nested widgets if you care.

Observation is the glue, not a third store#

SwiftUI tracks which observables a body read. FlowPageView reads store.state, store.page, store.registry, store.stateStore, and pagination flags. Widget bodies read bindings into WidgetStateStore. A stepper tick should not re-decode the page. A refresh should not require the stepper view to exist to clear it. Two objects, two reasons to invalidate.

Mutations sit on the document side#

PageMutation is replacePage, appendSections, prependSections, replaceWidget, removeWidget. apply edits the loaded PageModel and assigns state again so Observation fires. replacePage can load a document even if the store was not .loaded. Widget state is not automatically reset on mutation. If an api replace should clear a stepper, the host can reset, or the widget id can change, or the payload can carry a new initial and the widget can choose to read it. The package does not guess.

performAction is a loader round trip for api. Decode of ActionResponse is detached, same idea as page decode: do not parse a large mutation on the main actor inside the handler.

What Observation does not replace#

It does not replace PageLoader. Bytes still come from the host. It does not replace the registry. Types still map to views. It does not replace ActionDispatcher. Taps still leave the view through WidgetContext.dispatch. Built-ins: toast, dismiss, refresh_page, open_bottom_sheet, api. Deeplinks remain host handlers. No router.

It does not make WidgetView off-main-actor. The protocol is @MainActor because View is. Register on the main actor before the first page appears.

Debug UnknownWidgetPolicy is .placeholder. Release is .skip. Those policies change what WidgetRowView shows. They are not stored in WidgetStateStore. Do not persist a placeholder as user state.

Practical split in a widget#

Read copy, images, and layout from content (decoded JSON). Read expansion and counts from context.state. Fire change or tap when the user commits something the backend should know. Let api mutations update the document. Let refresh reset ephemeral keys. Let pagination keep them.

Cancellation and races#

PageStore cancels an in-flight fetch when a newer one starts. Pagination uses a separate task. appendNextPage re-reads state after the await so an api replaceWidget that landed during the fetch is not overwritten by a stale capture. Observation only helps if you publish the merged document, not a captured copy.

Failed pagination does not assign .failed. The sentinel can appear again. Failed initial load does assign .failed and offers retry, which is loadInitial() again.

ActionDispatcher.cancelInFlight() exists so an api round trip cannot land against a page that was popped. Hosts should call it when tearing down. That is still document lifetime, not widget state.

What belongs where, one more time#

PageStore: loading / loaded / failed / empty, isLoadingMore, diagnostics, apply(PageMutation), loader kinds initial, refresh, nextPage(postback:), action. WidgetStateStore: keyed values and bindings. Observation: invalidation only.

The architecture concept lists the pipeline. The state concept is the short version of this split. State is the part of the pipeline that survives between renders without another fetch. PageStore is the fetch and the document. WidgetStateStore is the session inside the document. Observation is only how SwiftUI notices both.

PageStore.page is a convenience for .loaded. Do not cache that value in the view across a refresh. Read it from the store each body. isLoadingMore is the pagination spinner flag. It is not a fifth State case. Keep it separate so a loaded tree can show a sentinel without becoming .loading.

Refresh, pagination, and the user's count#

The user sets a stepper to 3. Pagination loads more sections. The count must stay 3 because loadNextPage does not reset the widget store. The user pulls to refresh. The count must return to the payload's initial because refresh calls stateStore.reset(). Get those two lines wrong and you will ship a "SDUI is buggy" note. They are not bugs. They are the lifecycle.

Mutations from api edit the document without resetting ephemeral keys unless you reset or change ids. If a replace should clear a draft, change the widget id or call reset from the host. The package will not guess. Observation will show whatever you published. Be explicit.

More on the blog.

Read the source

Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.

GitHub

Get started with Flow-UI

Install the Swift package, register a widget, and render a page from JSON.

Get started