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
- Parent State Binding
- View Modifiers
- Skeleton Loading (Placeholder)
- Load More Pagination
- Environment CRUD Callbacks
- 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.1.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 @MainActor
final class FeatureViewState: LoadmoreScreenState, StateUpdatable {
// UI Configuration
let headerHeight: CGFloat = 120.0
// Data State
var items: [Item] = []
}import Foundation
import ScreenStateKit
actor FeatureViewStore: ScreenActionStore {
// MARK: - Dependencies
private let dataService: DataServiceProtocol
// MARK: - State Management
private let actionLocker = ActionLocker.nonIsolated
private(set) weak var viewState: FeatureViewState?
// MARK: - Init
init(dataService: DataServiceProtocol) {
self.dataService = dataService
}
// MARK: - Actions
enum Action: ActionLockable, LoadingTrackable, Hashable {
case fetchItems
case loadMore
var canTrackLoading: Bool {
switch self {
case .fetchItems:
return true
case .loadMore:
return false
}
}
}
// MARK: - ScreenActionStore Protocol
func binding(state: FeatureViewState) {
self.viewState = state
}
// MARK: - Action Processing
func receive(action: Action) async throws {
guard actionLocker.canExecute(action) else { return }
defer { actionLocker.unlock(action) }
switch action {
case .fetchItems:
try await fetchItems()
case .loadMore:
try await loadMoreItems()
}
}
// MARK: - Action Implementations
private func fetchItems() async throws {
let result = try await dataService.fetchItems(page: 1, limit: 20)
await viewState?.updateState { state in
state.items = result.items
}
}
private func loadMoreItems() async throws {
let currentItems = await viewState?.items ?? []
let result = try await 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
struct FeatureView: View {
// MARK: - State
@State private var viewState: FeatureViewState
@State private var viewStore: FeatureViewStore
// MARK: - Init
init(viewState: FeatureViewState, viewStore: FeatureViewStore) {
self.viewState = viewState
self.viewStore = viewStore
}
// MARK: - Body
var body: some View {
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
@ViewBuilder
private func contentBody() -> some View {
if viewState.items.isEmpty && !viewState.isLoading {
emptyStateView()
} else {
itemListView()
}
}
private func itemListView() -> some View {
List {
ForEach(viewState.items) { item in
ItemRow(item: item)
}
// Load more indicator
if !viewState.items.isEmpty && viewState.canShowLoadmore {
RMLoadmoreView(states: viewState)
}
}
.refreshable {
try? await viewStore.receive(action: .fetchItems)
}
}
private func emptyStateView() -> some View {
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.
@MainActor
public protocol StateUpdatable {
func updateState(
_ updateBlock: @MainActor (_ state: Self) -> Void,
withAnimation animation: Animation?,
disablesAnimations: Bool
)
}Conform your state class to StateUpdatable to gain the updateState method:
@Observable @MainActor
final class MyViewState: ScreenState, StateUpdatable {
var items: [Item] = []
var title: String = ""
}// Basic state update (no animation)
await viewState?.updateState { state in
state.items = newItems
state.title = "Updated"
}
// Update with animation
await viewState?.updateState({ state in
state.items = newItems
}, withAnimation: .easeInOut)
// Update with animations disabled
await viewState?.updateState({ state in
state.items = newItems
}, disablesAnimations: true)ScreenState supports parent-child relationships, where loading and error states propagate upward from a child state to a parent.
public struct BindingParentStateOption: OptionSet, Sendable {
public static let loading // Propagate loading state
public static let error // Propagate error state
public static let all // Propagate both (default)
}@Observable @MainActor
final class ParentViewState: ScreenState { }
@Observable @MainActor
final class ChildViewState: ScreenState {
init(parent: ParentViewState) {
// Propagate both loading and error to parent
super.init(states: parent)
}
}
// Or selectively propagate only loading:
final class ChildViewState: 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).
struct HomeSnapshot: Equatable, PlaceholderRepresentable {
let items: [Item]
static var placeholder: HomeSnapshot {
HomeSnapshot(items: Item.mocks)
}
var isPlaceholder: Bool { self == .placeholder }
}The .placeholder() modifier applies .redacted(reason: .placeholder) automatically when the value is a placeholder instance:
ForEach(viewState.snapshot.items) { item in
ItemCardView(item: item)
}
.placeholder(viewState.snapshot)Pair it with a shimmer library for a polished skeleton loading effect:
ForEach(viewState.snapshot.items) { item in
ItemCardView(item: item)
}
.placeholder(viewState.snapshot)
.shimmering(active: viewState.snapshot.isPlaceholder)@Observable @MainActor
final class HomeViewState: ScreenState, StateUpdatable {
var snapshot: 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 @MainActor
final class ListViewState: LoadmoreScreenState, StateUpdatable {
var items: [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 in
ItemRow(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 |
struct ItemListView: View {
@State private var viewState = ItemListViewState()
@State private var viewModel: ItemListViewModel
@State private var showCreateSheet = false
@State private var selectedItem: Item?
var body: some View {
List(viewState.items) { item in
ItemRow(item: item)
.onTapGesture { selectedItem = item }
}
.sheet(isPresented: $showCreateSheet) {
CreateItemView()
}
.sheet(item: $selectedItem) { item in
EditItemView(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)
}
}
}struct EditItemView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.onEditedAction) private var onEditedAction
@Environment(\.onDeletedAction) private var onDeletedAction
@Environment(\.onCancelledAction) private var onCancelledAction
let item: Item
@State private var editedName: String
@State private var showDeleteConfirmation = false
var body: some View {
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 {
await updateItem()
await onEditedAction?.asyncExecute()
dismiss()
}
}
}
}
.alert("Delete Item?", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
Task {
await deleteItem()
await onDeletedAction?.asyncExecute()
dismiss()
}
}
Button("Cancel", role: .cancel) {}
}
}
}
}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
let refreshAction: AsyncActionVoid = .init {
await dataStore.refresh()
}
refreshAction.execute()
// Action that returns data
let getSettings: AsyncActionGet<Settings> = .init {
return await settingsManager.currentSettings
}
let settings = try await getSettings.asyncExecute()
// Action that takes input but returns nothing
let saveItem: AsyncActionPut<Item> = .init { item in
await itemStore.save(item)
}
saveItem.execute(myItem)
// Full input/output action
let fetchUser: AsyncAction<String, User> = .init { userId in
return try await userService.fetchUser(id: userId)
}
let user = try await 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
let eventProducer = StreamProducer<UserEvent>()
// Emit events from anywhere
await eventProducer.emit(element: .userLoggedIn(user))
await eventProducer.emit(element: .profileUpdated(profile))
// Subscribe to events
Task {
for await event in await 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
let producer = StreamProducer<Int>(element: 0, withLatest: true)
// New subscribers only get future elements
let producer = 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.
actor MyViewModel {
private let cancelBag = CancelBag(onDuplicate: .cancelExisting)
private let eventProducer = StreamProducer<DataEvent>()
func startObserving() {
// Store task with identifier for later cancellation
Task.detached { [weak self] in
guard let stream = await self?.eventProducer.stream else { return }
for await event in stream {
await self?.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
let wrappedStream = someAsyncSequence.anyAsyncStream
// Use in generic contexts
func observe<T>(stream: AnyAsyncStream<T>) async {
while let 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:disablesAnimations:) for batched state updates |
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) |
| 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 |
| 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 |
MIT License
Built with Swift's modern concurrency features including async/await, actors, and the @Observable macro.


