How the SwiftUI App and WidgetKit Read the Same State

A structure where SwiftData and App Group snapshots keep values consistent outside the app

Hangangjari’s iOS implementation does not end with showing API responses on screen. The app, widgets, SwiftData cache, App Group snapshots, deep links, and external map-app handoff all move together. In this context, an App Group snapshot is a small bundle of display-ready values shared by the app and widgets.

The app is used for short checks before going to the Han River and while being there. It was not enough for information to appear only after opening the app. A frequently visited park had to be visible directly in the widget, and tapping the widget had to continue into the same park and screen the user had just been viewing.

At first, it is easy to think of the client as the place that arranges API values on a screen. In practice, that was not true. Hangangjari is checked in short moments: before leaving home, before taking the subway, near a parking-lot entrance, or while looking for facilities inside a park.

Even if the network is briefly slow or a widget is reading an old snapshot, the screen has to explain the state so users do not misunderstand it.

What mattered most on iOS was therefore not the screen itself, but where the same state is stored and how it is read again. API responses are stored in SwiftData, and the app state store reshapes them into a form the UI can use.

Widgets read App Group snapshots, and deep links return to a specific park and screen in the app. If that connection breaks, each feature may still work in isolation, but the user feels a break in the experience.

Code shared by the app and widgets

The client is currently split into three targets.

  • Hangangjari: the SwiftUI main app.
  • HangangjariWidget: parking and general park widgets.
  • HangangjariTests: tests for model, networking, cache, and widget logic.

The app and widgets share model, networking, repository, localization, and widget snapshot code. The WidgetKit extension is not the same process as the app, so values that both sides need are written separately into the App Group area.

flowchart LR
  View["SwiftUI view"] --> Store["App state store\n@Observable @MainActor"]
  Store --> ParkingRepo["Parking repository"]
  Store --> ForecastRepo["Forecast repository"]
  ParkingRepo --> API["API client"]
  ForecastRepo --> API
  ParkingRepo --> Cache["SwiftData cache"]
  ForecastRepo --> Cache
  Store --> Snapshot["Widget snapshot store"]

  Widget["WidgetKit provider"] --> Loader["Widget entry loader"]
  Loader --> Snapshot
  Loader --> API
  Loader --> Location["Widget location resolver"]
  Settings["Widget / favorite settings"] --> AppGroup["App Group UserDefaults"]
  Widget --> AppGroup
  API --> Backend["FastAPI /v2"]

Screen state is coordinated in one place

The app state store is the object that gathers screen state. The implementation name matters less than the fact that this object organizes what the screen needs to check and pushes network and storage details outward.

The current state contains these groups.

  • Park list.
  • Parking-lot lists per park and the latest status per parking lot.
  • Outing overview per park.
  • First-screen HomeSummary.
  • Parking forecast overview and timeline.
  • Park congestion forecast overview and timeline.
  • Screen state derived from favorites, widgets, and deep links.

The important point is that the app state store does not know HTTP implementation details directly. API calls and SwiftData persistence belong to repositories. The store focuses on state transitions needed by the screen and on building display shapes.

The goal was not to make the store large. I kept moving work the store did not need to know outside it. URL construction, request proof, decoding, and SwiftData row upsert moved out of the store.

Inside the store, I tried to leave only UI-ready state such as “this park is being viewed,” “this value is loading,” and “this snapshot can be displayed.”

Repositories coordinate cache and API

The parking repository handles parks, lots, statuses, outing overview, and home summary. The forecast repository handles forecast config, parking forecasts, park congestion forecasts, and timeline payloads.

Repositories are not just API wrappers. They also decide local cache freshness, store API responses, and update widget snapshots.

sequenceDiagram
  autonumber
  participant View as SwiftUI view
  participant Store as App state store
  participant Repo as Repository
  participant Cache as SwiftData cache
  participant API as API client
  participant Snapshot as Widget snapshot store

  View->>Store: Request first-screen values
  Store->>Repo: Check stored first-screen values
  Repo->>Cache: Read cached response
  alt Cache is fresh
    Cache-->>Repo: Usable first-screen value
    Repo-->>Store: Screen model from cache
  else Stale or missing
    Repo->>API: Request first-screen response
    API-->>Repo: Decoded first-screen response
    Repo->>Cache: Store home / parking / forecast values
    Repo-->>Store: Fresh screen model
  end
  Store->>Snapshot: Update parking/general widget display values
  Store-->>View: Update screen state

With this approach, the first screen does not wait for several APIs one by one. The server sends a screen-ready response, the app preserves it in SwiftData, and then the app splits it into shapes for the screen and widgets.

This makes the code a little more verbose. Compared with simply calling api.homeSummary() and placing the result on screen, repository, cache, and snapshot layers add more steps.

The tradeoff is that it becomes easier to find where to defend behavior when the app reopens, when a widget fails to reach the network, or when the server adds a field. Mobile apps hit these situations more often than expected.

WidgetKit runs outside the app

Hangangjari has two widget types.

  • Parking widget: recommended parking lot, remaining spaces, status, and nearby sorting.
  • General park widget: congestion, weather, event and facility signals, and information for deciding whether to go today.

The widget reads the stored snapshot first. If it is fresh, it uses it. If it is missing or old, it calls the API. If that fails, it falls back to a stale snapshot or placeholder.

flowchart TB
  Provider["Widget timeline provider"] --> Request["Widget load request"]
  Request --> Loader["Widget entry loader"]
  Loader --> Cached["App Group snapshot"]
  Loader --> API["home-summary / forecast / status API"]
  Loader --> Location["Selected widget location"]
  API --> Builder["Snapshot builder"]
  Cached --> Entry["Widget display entry"]
  Builder --> Entry
  Entry --> Timeline["Timeline\nrefresh policy"]

The parking widget and general widget use separate snapshot keys. Even for the same park, the first thing each widget should say is different.

The app and widgets share the same API structure

The API client calls the v2 app API. Write requests or sensitive requests have a path for attaching an app access token and request proof.

For app access validation, I kept these boundaries.

  • The app and widgets share the same API client structure.
  • Bootstrap flow is separated from protected v2 app APIs.
  • Whether unsigned fallback is allowed on failure is controlled by runtime policy.
  • Write requests such as telemetry and push subscription go through stronger validation than read requests.

Continuous state is part of the product experience

WidgetKit is not a small view inside the app; it is a separate execution environment. That means App Group snapshots, repositories, screen-ready server responses, and deep links are not enough if they each work separately. The user has to resume the park and state they just saw.

fresh, stale, and unavailable are UI states created jointly by server and app. Whether a value is old, whether it can be read again, and whether opening the app returns to the same park all have to line up for the short checks before and after movement to feel continuous.

Looking back, the hard part on iOS was not splitting screens. It was preventing state from breaking apart. The app, widgets, cache, and deep links have to inherit the same park and the same value before the user feels that “the information I just saw” has continued.

Share

Share

Image preview