A comprehensive, fully tested Swift state management and UI toolkit for iOS 17+ applications built with SwiftUI. This framework provides reactive state containers, async action handling, task lifecycle management, and pre-built UI components for common patterns like loading states, error handling, and pagination.
Main Contributor:@ThangKM
Check out the Definery app for a real-world example of ScreenStateKit in action.
- Requirements
- Installation
- Architecture Overview
- Complete Feature Example
- StateUpdatable
- Reading State (readState)
- Parent State Binding
- View Modifiers
- Skeleton Loading (Placeholder)
- Load More Pagination
- Environment CRUD Callbacks
- App Refresh Bus
- AsyncAction
- Async Streaming
- API Reference
- License
- iOS 17.0+ / macOS 14.0+
- Swift 5.9+
- Xcode 15.0+
Add the following to your Package.swift:
dependencies:[.package(url:"https://github.com/anthony1810/ScreenStateKit.git", from:"1.4.0")]Or in Xcode: File > Add Package Dependencies and enter:
https://github.com/anthony1810/ScreenStateKit.git
ScreenStateKit promotes a clean architecture pattern for building features with three core components:
- State (
ScreenStatesubclass) - Observable state container that holds all UI-related data - Action Dispatcher (
ScreenActionStoreconforming actor) - ViewModel or Store that processes actions - View - SwiftUI view that binds state to dispatcher and triggers actions
Here's a complete example showing how to build a feature using ScreenStateKit's architecture:
import Foundation
import ScreenStateKit
import Observation
@Observable@MainActorfinalclassFeatureViewState:LoadmoreScreenState,StateUpdatable{
// UI Configuration
letheaderHeight:CGFloat=120.0
// Data State
varitems:[Item]=[]}import Foundation
import ScreenStateKit
actorFeatureViewStore:ScreenActionStore{
// MARK: - Dependencies
privateletdataService:DataServiceProtocol
// MARK: - State Management
privateletactionLocker=ActionLocker.nonIsolated
private(set) weak varviewState:FeatureViewState?
// MARK: - Init
init(dataService:DataServiceProtocol){self.dataService = dataService
}
// MARK: - Actions
enumAction:ActionLockable,LoadingTrackable,Hashable{case fetchItems
case loadMore
varcanTrackLoading:Bool{switchself{case.fetchItems:returntruecase.loadMore:returnfalse}}}
// MARK: - ScreenActionStore Protocol
func binding(state:FeatureViewState){self.viewState = state
}
// MARK: - Action Processing
func receive(action:Action)asyncthrows{guard actionLocker.canExecute(action)else{return}defer{ actionLocker.unlock(action)}switch action {case.fetchItems:tryawaitfetchItems()case.loadMore:tryawaitloadMoreItems()}}
// MARK: - Action Implementations
privatefunc fetchItems()asyncthrows{letresult=tryawait dataService.fetchItems(page:1, limit:20)await viewState?.updateState{ state in
state.items = result.items
}}privatefunc loadMoreItems()asyncthrows{letcurrentItems=await viewState?.items ??[]letresult=tryawait dataService.fetchItems(page:2, limit:20)await viewState?.updateState{ state in
state.items = currentItems + result.items
}}}How it works: You only write business logic in
receive(action:)andthrowon errors. The framework'sdispatchmethod (called bynonisolatedReceive) automatically handlesloadingStarted,loadingFinished, and error routing toviewState?.showError(). No boilerplate needed.
Action Flow: Here's how actions are processed through ActionLocker and LoadingTrackable:
import SwiftUI
import ScreenStateKit
structFeatureView:View{
// MARK: - State
@StateprivatevarviewState:FeatureViewState@StateprivatevarviewStore:FeatureViewStore
// MARK: - Init
init(viewState:FeatureViewState, viewStore:FeatureViewStore){self.viewState = viewState
self.viewStore = viewStore
}
// MARK: - Body
varbody:someView{ZStack{Color(.systemBackground).ignoresSafeArea()contentBody()}.onShowLoading($viewState.isLoading).onShowError($viewState.displayError).task{
// Critical: Bind state to viewStore
await viewStore.binding(state: viewState)
// Initial data fetch
viewStore.nonisolatedReceive(action:.fetchItems)}}
// MARK: - Content
@ViewBuilderprivatefunc contentBody()->someView{if viewState.items.isEmpty && !viewState.isLoading {emptyStateView()}else{itemListView()}}privatefunc itemListView()->someView{List{ForEach(viewState.items){ item inItemRow(item: item)}
// Load more indicator
if !viewState.items.isEmpty && viewState.canShowLoadmore {RMLoadmoreView(states: viewState)}}.refreshable{try?await viewStore.receive(action:.fetchItems)}}privatefunc emptyStateView()->someView{VStack(spacing:16){Image(systemName:"tray").font(.system(size:48)).foregroundStyle(.secondary)Text("No Items").font(.title2)Text("Pull down to refresh").font(.subheadline).foregroundStyle(.secondary)}}}The StateUpdatable protocol provides a safe way to batch state updates with optional animation and transaction control.
@MainActorpublicprotocolStateUpdatable:Sendable{func updateState(
withAnimation animation:Animation?,
_ updateBlock:@MainActor@Sendable(_ state:Self)->Void)}Conform your state class to StateUpdatable to gain the updateState method:
@Observable@MainActorfinalclassMyViewState:ScreenState,StateUpdatable{varitems:[Item]=[]vartitle:String=""}// Default: animated with .smooth
await viewState?.updateState{ state in
state.items = newItems
state.title ="Updated"}
// Update with a custom animation
await viewState?.updateState(withAnimation:.easeInOut){ state in
state.items = newItems
}
// Update with animations disabled
await viewState?.updateState(withAnimation:.none){ state in
state.items = newItems
}Why
updateStateinstead of mutating directly? The state is@MainActor-isolated. From the actor store, every direct property write (viewState?.items = …) is its own hop onto the MainActor, so a multi-property mutation tears across several hops and renders in several SwiftUI passes.updateStatebatches all writes into one MainActor hop inside a singlewithTransaction, so the screen updates atomically with one animation. Always funnel writes through it — never mutate the state from outside the store.
readState is the read-side counterpart to updateState. The store often needs to read several current values off the @MainActor state before computing the next update (e.g. the existing list before appending a page). Reading them one-by-one is unsafe:
// ❌ Each await is a separate MainActor hop — the state can change between them.
letitems=await viewState?.items ??[]letisOpen=await viewState?.isShowing ??false // may reflect a DIFFERENT state than `items`readState collects all the reads inside one closure executed in a single MainActor hop, returning a consistent snapshot — a single value, or a tuple of matching arity:
// ✅ One hop, one coherent snapshot.
guardlet snapshot =await viewState?.readState({ state in
state.items
state.isShowing
})else{return}letitems= snapshot.0 // [Item]
letisOpen= snapshot.1 // BoolNaming the tuple at the call site reads best:
let(items, isOpen):([Item],Bool)=await viewState?.readState{ state in
state.items
state.isShowing
}??([],false)A single value comes back unwrapped (no 1-tuple):
lettitle:String=await viewState?.readState{ $0.title }??""It is backed by StateValueBuilder, a result builder using parameter packs, so it supports any number of values without per-arity overloads. The block returns Sendable values, so it is safe to carry the snapshot back into the actor.
When to use it: reach for
readStatewhenever you need two or more related values atomically. For a single value, plainawait viewState?.foois equally safe andreadStatebuys nothing.
ScreenState supports parent-child relationships, where loading and error states propagate upward from a child state to a parent.
publicstructBindingParentStateOption:OptionSet,Sendable{publicstaticletloading // Propagate loading state
publicstaticleterror // Propagate error state
publicstaticletall // Propagate both (default)
}@Observable@MainActorfinalclassParentViewState:ScreenState{}@Observable@MainActorfinalclassChildViewState:ScreenState{init(parent:ParentViewState){
// Propagate both loading and error to parent
super.init(states: parent)}}
// Or selectively propagate only loading:
finalclassChildViewState:ScreenState{init(parent:ParentViewState){
super.init(states: parent, options:.loading)}}When the child's isLoading changes or displayError is set, the parent state is automatically updated.
Automatically displays error alerts when error state changes.
.onShowError($viewState.displayError)Shows centered circular progress indicator with opacity animation.
.onShowLoading($viewState.isLoading)Shows full-screen semi-transparent loading overlay that blocks interaction.
.onShowBlockLoading($viewState.isLoading, subtitles:"Saving...")ScreenStateKit provides a PlaceholderRepresentable protocol and .placeholder() view modifier for skeleton loading effects using SwiftUI's built-in .redacted(reason: .placeholder).
structHomeSnapshot:Equatable,PlaceholderRepresentable{letitems:[Item]staticvarplaceholder:HomeSnapshot{HomeSnapshot(items:Item.mocks)}varisPlaceholder:Bool{self==.placeholder }}The .placeholder() modifier applies .redacted(reason: .placeholder) automatically when the value is a placeholder instance:
ForEach(viewState.snapshot.items){ item inItemCardView(item: item)}.placeholder(viewState.snapshot)Pair it with a shimmer library for a polished skeleton loading effect:
ForEach(viewState.snapshot.items){ item inItemCardView(item: item)}.placeholder(viewState.snapshot).shimmering(active: viewState.snapshot.isPlaceholder)@Observable@MainActorfinalclassHomeViewState:ScreenState,StateUpdatable{varsnapshot:HomeSnapshot=.placeholder // Start with skeleton
}Once real data loads, update the snapshot and the redaction is automatically removed.
Extend LoadmoreScreenState instead of ScreenState to get built-in pagination support:
@Observable@MainActorfinalclassListViewState:LoadmoreScreenState,StateUpdatable{varitems:[Item]=[]}Properties:
canShowLoadmore: Bool(read-only) - Whether the load more indicator should be visibledidLoadAllData: Bool(read-only) - Whether all data has been loaded
Methods:
canExecuteLoadmore()- Enables the load more indicator (no-op ifdidLoadAllDatais true)updateDidLoadAllData(_ didLoadAllData: Bool)- Updates thedidLoadAllDataflag and togglescanShowLoadmoreterminateLoadMoreView()- Hides the load more indicator
A pre-built ProgressView that automatically calls canExecuteLoadmore() when it disappears (scrolled past):
List{ForEach(viewState.items){ item inItemRow(item: item)}if viewState.canShowLoadmore {RMLoadmoreView(states: viewState)}}Environment-based action callbacks for passing actions down the view hierarchy. Perfect for CRUD operations where child views need to notify parents of changes.
Available Modifiers:
| Modifier | Description |
|---|---|
.onEdited(_ action:) | Set edited callback |
.onDeleted(_ action:) | Set deleted callback |
.onCreated(_ action:) | Set created callback |
.onCancelled(_ action:) | Set cancelled callback |
structItemListView:View{@StateprivatevarviewState=ItemListViewState()@StateprivatevarviewModel:ItemListViewModel@StateprivatevarshowCreateSheet=false@StateprivatevarselectedItem:Item?varbody:someView{List(viewState.items){ item inItemRow(item: item).onTapGesture{ selectedItem = item }}.sheet(isPresented: $showCreateSheet){CreateItemView()}.sheet(item: $selectedItem){ item inEditItemView(item: item)}
// Parent sets callbacks for child views to trigger
.onCreated{[weak viewModel]in
viewModel?.nonisolatedReceive(action:.refreshItems)}.onEdited{[weak viewModel]in
viewModel?.nonisolatedReceive(action:.refreshItems)}.onDeleted{[weak viewModel]in
viewModel?.nonisolatedReceive(action:.refreshItems)}}}structEditItemView:View{@Environment(\.dismiss)privatevardismiss@Environment(\.onEditedAction)privatevaronEditedAction@Environment(\.onDeletedAction)privatevaronDeletedAction@Environment(\.onCancelledAction)privatevaronCancelledActionletitem:Item@StateprivatevareditedName:String@StateprivatevarshowDeleteConfirmation=falsevarbody:someView{NavigationStack{Form{TextField("Item Name", text: $editedName)Button("Delete Item", role:.destructive){
showDeleteConfirmation =true}}.toolbar{ToolbarItem(placement:.cancellationAction){Button("Cancel"){
onCancelledAction?.execute()dismiss()}}ToolbarItem(placement:.confirmationAction){Button("Save"){Task{awaitupdateItem()await onEditedAction?.asyncExecute()dismiss()}}}}.alert("Delete Item?", isPresented: $showDeleteConfirmation){Button("Delete", role:.destructive){Task{awaitdeleteItem()await onDeletedAction?.asyncExecute()dismiss()}}Button("Cancel", role:.cancel){}}}}}A lightweight, app-wide "refresh signal" bus built on the SwiftUI environment and the @Observable macro (no Combine). It lets any screen broadcast "something changed, reload X" and lets any other screen react — without the two knowing about each other. It is generic over two types you define:
Option(anOptionSet) — what to refresh. Consumers filter on it, and you can combine signals ([.inbox, .settings]) in one call.Source(anySendable, typically anenum) — an optional payload delivered to observers. Its associated values carry the fresh object, so a consumer can update without touching a local DB (e.g. hand it a brand-newSession).
AppRefresher<Option, Source>—@MainActor @Observablebroadcaster holding the latestaction. Callrefresh(_:source:)to publish.AppRefreshAction<Option, Source>— wraps theoption, the optionalsourcepayload, and a freshid: UUIDon every call. The UUID makes each signal unique, so consumers still react when the same option fires twice in a row (SwiftUI would otherwise dedupe identical values).
import ScreenStateKit
structRefreshOption:OptionSet,Sendable{letrawValue:IntstaticletinboxMessage=RefreshOption(rawValue:1 << 0)staticletteamSettings=RefreshOption(rawValue:1 << 1)}enumRefreshSource:Sendable{case newSetting(TeamSetting) // associated value = the payload object
case newSession(Session)}typealiasRefresher=AppRefresher<RefreshOption,RefreshSource>// Auto-create and inject:
RootView().appRefresherHost(option:RefreshOption.self, source:RefreshSource.self)
// Or inject a shared instance you own (e.g. to also send from a store):
RootView().appRefresherHost(myRefresher)structEditSettingScreen:View{@Environment(Refresher.self)privatevarrefreshervarbody:someView{Button("Save"){Task{letsetting=await store.save()
refresher?.refresh(.teamSettings, source:.newSetting(setting))}}}}structInboxScreen:View{varbody:someView{List(/* ... */){ /* ... */ }
// .onNextAppear (default): defers until the screen is next visible
.onAppRefresh(RefreshOption.teamSettings){(source:RefreshSource?)inif case let.newSetting(setting)= source {
store.apply(setting) // use the payload directly
}else{Task{await store.receive(.reload)}}}
// .immediate: runs right away, even while off-screen
.onAppRefresh(RefreshOption.inboxMessage, behavior:.immediate){ _ inTask{await store.receive(.reload)}}}}Spell the option's type at the call site (
RefreshOption.teamSettings) — chained modifiers give no contextual type for leading-dot syntax to infer the genericOptionfrom.
| Behavior | When the action runs |
|---|---|
.onNextAppear (default) | Stores the request; runs on the view's next onAppear. Ideal for hidden screens — no point reloading a list the user can't see. |
.immediate | Runs the instant the signal fires, even if the view is off-screen. |
Using it from a Store: The bus is read through the SwiftUI environment, so the View is the boundary that listens. Forward into your
ScreenActionStorefrom the closure —.onAppRefresh(RefreshOption.inboxMessage) { _ in Task { await store.receive(.reload) } }— keeping the store free of any SwiftUI/environment coupling.
A generic wrapper for async/await operations with configurable input and output types.
Type Aliases:
| Alias | Definition | Use Case |
|---|---|---|
AsyncActionVoid | AsyncAction<Void, Void> | No input, no output |
AsyncActionGet<Output> | AsyncAction<Void, Output> | No input, returns output |
AsyncActionPut<Input> | AsyncAction<Input, Void> | Takes input, no output |
// Fire and forget action
letrefreshAction:AsyncActionVoid=.init {await dataStore.refresh()}
refreshAction.execute()
// Action that returns data
letgetSettings:AsyncActionGet<Settings>=.init {returnawait settingsManager.currentSettings
}letsettings=tryawait getSettings.asyncExecute()
// Action that takes input but returns nothing
letsaveItem:AsyncActionPut<Item>=.init { item inawait itemStore.save(item)}
saveItem.execute(myItem)
// Full input/output action
letfetchUser:AsyncAction<String,User>=.init { userId inreturntryawait userService.fetchUser(id: userId)}letuser=tryawait fetchUser.asyncExecute("user-123")A multi-consumer async event emitter (actor-based) that allows multiple subscribers to receive events. Conforms to the StreamProducerType protocol.
// Create a stream producer
leteventProducer=StreamProducer<UserEvent>()
// Emit events from anywhere
await eventProducer.emit(element:.userLoggedIn(user))await eventProducer.emit(element:.profileUpdated(profile))
// Subscribe to events
Task{forawaiteventinawait eventProducer.stream {switch event {case.userLoggedIn(let user):print("User logged in: \(user.name)")case.profileUpdated(let profile):print("Profile updated")}}}
// Finish the stream when done
await eventProducer.finish()Options:
withLatest: Bool(defaulttrue) - Whentrue, new subscribers immediately receive the most recently emitted element.
// New subscribers get the latest element immediately
letproducer=StreamProducer<Int>(element:0, withLatest:true)
// New subscribers only get future elements
letproducer=StreamProducer<Int>(withLatest:false)Non-isolated methods for use from nonisolated contexts:
nonIsolatedEmit(_ element:)- Emits from a non-isolated contextnonIsolatedFinish()- (Deprecated) Streams are automatically finished when the producer is deallocated
Manages and cancels multiple async tasks. Completed tasks are automatically removed from the bag. All remaining tasks are cancelled when the bag is deallocated.
actorMyViewModel{privateletcancelBag=CancelBag(onDuplicate:.cancelExisting)privateleteventProducer=StreamProducer<DataEvent>()func startObserving(){
// Store task with identifier for later cancellation
Task.detached{[weak self]inguardlet stream =awaitself?.eventProducer.stream else{return}forawaiteventin stream {awaitself?.handleEvent(event)}}.store(in: cancelBag, withIdentifier:"eventObserver")}func stopObserving()async{await cancelBag.cancel(forIdentifier:"eventObserver")}}Init — DuplicatePolicy:
CancelBag(onDuplicate: .cancelExisting)- When a new task is stored with the same identifier, cancel the existing oneCancelBag(onDuplicate: .cancelNew)- When a new task is stored with the same identifier, cancel the new one
Properties:
isEmpty: Bool- Whether the bag has no running taskscount: Int- Number of running tasks
Methods:
cancelAll()- Cancels all stored taskscancel(forIdentifier:)- Cancels a specific task by its identifier (acceptsAnyHashable)
Task extension:
task.store(in: cancelBag)- Store with auto-generated identifier, returnsAnyTasktask.store(in: cancelBag, withIdentifier: id)- Store with a specific identifier, returnsAnyTask
AnyTask:
cancel()- Cancel the underlying taskwaitComplete()- Await completion of the underlying taskisCancelled: Bool- Whether the task has been cancelled
Type-erased wrapper for any AsyncSequence, useful for abstracting different stream types.
// Wrap any async sequence
letwrappedStream= someAsyncSequence.anyAsyncStream
// Use in generic contexts
func observe<T>(stream:AnyAsyncStream<T>)async{whilelet value =try?await stream.next(){process(value)}}| Protocol | Purpose |
|---|---|
ScreenActionStore | Actor-based protocol for ViewModels. Requires receive(action:) async throws and viewState. Provides nonisolatedReceive and centralized dispatch for loading/error handling |
ActionLockable | Provides a lockKey for action deduplication. Auto-conforms for Hashable types |
NonPresentableError | Protocol for errors that should be logged but not shown to the user (isSilent: Bool) |
LoadingTrackable | Declares whether an action should track loading state via canTrackLoading |
StateUpdatable | Provides updateState(withAnimation:_:) for batched writes and readState(_:) for atomic multi-value reads |
PlaceholderRepresentable | Declares placeholder and isPlaceholder for skeleton loading |
StreamProducerType | Actor protocol for multi-subscriber async stream producers |
TypeNamed | Provides declaredName and typeNamed for type name reflection |
| Class | Purpose |
|---|---|
ScreenState | @Observable @MainActor base class with loading counter, error handling, and parent binding |
LoadmoreScreenState | Extends ScreenState with pagination state (canShowLoadmore, didLoadAllData) |
AppRefresher<Option, Source> | @Observable @MainActor app-wide refresh bus. Call refresh(_:source:) to broadcast an OptionSet plus an optional payload |
| Actor | Purpose |
|---|---|
ActionLocker | Prevents duplicate action execution. Two variants: .isolated (actor) and .nonIsolated (class) |
CancelBag | Task lifecycle management with DuplicatePolicy, auto-removal of completed tasks, and identifier-based cancellation |
StreamProducer<Element> | Multi-subscriber async stream with optional latest-value replay |
| Struct | Purpose |
|---|---|
AsyncAction<Input, Output> | Generic async action wrapper with execute, asyncExecute, and #isolation support |
DisplayableError | LocalizedError wrapper with originalError, isSilent, and error routing support |
AnyTask | Public handle to a stored task with cancel(), waitComplete(), and isCancelled |
AnyAsyncStream<Element> | Type-erased AsyncSequence wrapper |
RMLoadmoreView | Pre-built ProgressView for load-more pagination |
AppRefreshAction<Option, Source> | Envelope carrying the option, an optional source payload, and a unique id per emission |
AppRefreshBehavior | Delivery mode for onAppRefresh: .onNextAppear (default) or .immediate |
StateValueBuilder | @resultBuilder powering readState(_:); uses parameter packs to return a single value or a tuple of any arity |
| Modifier | Purpose |
|---|---|
.onShowError(_:) | Displays error alert from DisplayableError? binding |
.onShowLoading(_:) | Shows centered progress indicator |
.onShowBlockLoading(_:subtitles:) | Shows full-screen blocking loading overlay |
.placeholder(_:) | Applies .redacted(reason: .placeholder) for skeleton loading |
.onEdited(_:) | Environment callback for edit actions |
.onDeleted(_:) | Environment callback for delete actions |
.onCreated(_:) | Environment callback for create actions |
.onCancelled(_:) | Environment callback for cancel actions |
.appRefresherHost(option:source:) | Creates and injects an AppRefresher into the environment (overload accepts a shared instance) |
.onAppRefresh(_:behavior:perform:) | Reacts to an option, delivering the Source? payload, with .onNextAppear (default) or .immediate behavior |
MIT License
Built with Swift's modern concurrency features including async/await, actors, and the @Observable macro.


