SwiftState is a lightweight, modern, and type-safe state management library for Swift and SwiftUI, built on modern Swift Concurrency features. It implements a predictable unidirectional data flow (Redux/MVI) and features a stunning, interactive Time Travel Debugger Panel that overlays directly onto your app.
- 🏎️ Modern Swift Concurrency: Native thread-safety using
@MainActorand Sendable types. - ⚡ Async Effects: Run API calls, persistence work, and other async operations from effect reducers.
- 🕒 Time Travel Engine: Automatic history recording with full
undo(),redo(), and manual scrubbing (jumping to any point in time). - 🧭 Clean History Timeline: Only records real state transitions, exposes combined state/action entries, and lets you reset history around the current state.
- 🧩 Composable Reducers: Split app logic into focused reducers and compose them back into one store.
- 🪄 SwiftUI Bindings: Bind controls directly to state reads while dispatching actions on writes.
- 🌐 Optional Networking Module: Add typed HTTP clients, retry, timeout, request lifecycle actions, and test mocks with
SwiftStateNetwork. - 🧬 Flexible Middlewares: Intercept actions before they reach reducers (e.g., logging, network synchronization).
- 📺 Glassmorphic SwiftUI Debugger: A premium floating console with timeline scrubbing and live JSON state inspection that can be toggled on debug builds.
- ⚙️ Optimized Render Updates: Only triggers SwiftUI view updates when the state changes (via
Equatablechecks).
flowchart LR
subgraph View ["UI Layer (SwiftUI / UIKit)"]
V[SwiftUI View]
end
subgraph StoreLayer ["SwiftState Store"]
A[Action] --> M[Middlewares]
M --> R[Reducer]
R --> S[(State)]
R --> E[Effect]
E --> A
end
V -- "dispatch(action)" --> A
S -- "publish state change" --> V
Add SwiftState to your project dependencies with Xcode:
- Open
File > Add Package Dependencies.... - Paste
https://github.com/ChiefVenzox/SwiftState.git. - Choose
Branchand entermainto use the latest SwiftState APIs. - Add the
SwiftStateproduct to your app target. - Add the
SwiftStateNetworkproduct too if you want networking middleware.
For a full Xcode walkthrough, see Documentation/XcodeIntegration.md.
Or append it directly to your Package.swift file:
dependencies: [
.package(url: "https://github.com/ChiefVenzox/SwiftState.git", branch: "main")
]For networking, add both products to your target:
.target(
name: "YourApp",
dependencies: ["SwiftState", "SwiftStateNetwork"]
)To try a ready-to-paste SwiftUI starter, see Examples/SwiftStateStarter.
SwiftStateNetwork is an optional module that integrates async HTTP requests into SwiftState's Action -> Middleware -> Reducer -> State flow.
import SwiftState
import SwiftStateNetworkCreate a client with a base URL:
struct Profile: Codable, Equatable, Sendable {
let id: Int
let name: String
}
let client = URLSessionHTTPClient(
baseURL: URL(string: "https://api.example.com")!
)
let profile: Profile = try await client.get("/profile")POST requests encode the body as JSON and decode the response:
struct CreateProfileBody: Encodable {
let name: String
}
let profile: Profile = try await client.post(
"/profile",
body: CreateProfileBody(name: "SwiftState")
)Add network request tracking to your app state:
struct AppState: State {
var profile: Profile?
var networkRequests: [String: NetworkRequestState] = [:]
var errorMessage: String?
}
enum AppAction: Action {
case profileLoaded(Profile)
case profileFailed(String)
}Handle NetworkAction in your reducer:
let appReducer: Reducer<AppState> = { state, action in
switch action {
case let action as NetworkAction:
switch action {
case .requestStarted(let request):
state.networkRequests[request.id] = request
case .requestSucceeded(let id, _, let duration):
guard let current = state.networkRequests[id] else { return }
state.networkRequests[id] = NetworkRequestState(
id: current.id,
method: current.method,
path: current.path,
status: .succeeded,
startedAt: current.startedAt,
finishedAt: current.startedAt.addingTimeInterval(duration),
duration: duration
)
case .requestFailed(let id, let error, let duration):
guard let current = state.networkRequests[id] else { return }
state.networkRequests[id] = NetworkRequestState(
id: current.id,
method: current.method,
path: current.path,
status: .failed,
startedAt: current.startedAt,
finishedAt: current.startedAt.addingTimeInterval(duration),
duration: duration,
errorMessage: error
)
}
case let action as AppAction:
switch action {
case .profileLoaded(let profile):
state.profile = profile
state.errorMessage = nil
case .profileFailed(let message):
state.errorMessage = message
}
default:
break
}
}Install the middleware:
let client = URLSessionHTTPClient(
baseURL: URL(string: "https://api.example.com")!
)
@StateObject private var store = Store(
initialState: AppState(),
reducer: appReducer,
middlewares: [
createNetworkMiddleware(client: client),
createLoggerMiddleware()
]
)Dispatch typed network requests from SwiftUI:
Button("Load Profile") {
store.dispatch(NetworkRequestAction<Profile>.get(
id: "load-profile",
"/profile",
retryPolicy: .times(2),
timeout: 10,
onSuccess: { AppAction.profileLoaded($0) },
onFailure: { AppAction.profileFailed($0) }
))
}The middleware dispatches request lifecycle actions:
NetworkAction.requestStarted(...)
NetworkAction.requestSucceeded(id:statusCode:duration:)
NetworkAction.requestFailed(id:error:duration:)struct ProfileView: View {
@EnvironmentObject var store: Store<AppState>
var body: some View {
VStack(spacing: 16) {
if let profile = store.state.profile {
Text(profile.name)
}
if let request = store.state.networkRequests["load-profile"],
request.status == .running {
ProgressView()
}
Button("Load Profile") {
store.dispatch(NetworkRequestAction<Profile>.get(
id: "load-profile",
"/profile",
retryPolicy: .times(2),
timeout: 10,
onSuccess: { AppAction.profileLoaded($0) },
onFailure: { AppAction.profileFailed($0) }
))
}
}
}
}func testProfileRequest() async throws {
let client = MockHTTPClient()
try await client.enqueue(Profile(id: 1, name: "Test User"))
let profile: Profile = try await client.get("/profile")
XCTAssertEqual(profile.name, "Test User")
let requests = await client.requests
XCTAssertEqual(requests[0].path, "/profile")
XCTAssertEqual(requests[0].method, .get)
}MockHTTPClient is included in SwiftStateNetwork; no external test dependency is needed.
Your application state must conform to State (which requires Codable and Equatable). Actions must conform to Action.
import SwiftState
struct AppState: State {
var counter: Int = 0
var textInput: String = ""
var isLoading: Bool = false
var message: String?
}
enum AppAction: Action {
case increment
case decrement
case changeText(String)
case loadMessage
case messageLoaded(String)
case messageFailed(String)
}Reducers are pure functions using the inout state pattern, making state mutation clean and simple:
let appReducer: Reducer<AppState> = { state, action in
guard let action = action as? AppAction else { return }
switch action {
case .increment:
state.counter += 1
case .decrement:
state.counter -= 1
case .changeText(let newText):
state.textInput = newText
case .loadMessage, .messageLoaded, .messageFailed:
break
}
}Use an EffectReducer when an action needs to start async work:
protocol MessageClient {
func fetchMessage() async throws -> String
}
let messageClient: MessageClient = LiveMessageClient()
let appEffectReducer: EffectReducer<AppState> = { state, action in
guard let action = action as? AppAction else { return .none }
switch action {
case .loadMessage:
state.isLoading = true
state.message = nil
return .run { dispatch in
do {
let message = try await messageClient.fetchMessage()
await dispatch(AppAction.messageLoaded(message))
} catch {
await dispatch(AppAction.messageFailed(error.localizedDescription))
}
}
case .messageLoaded(let message):
state.isLoading = false
state.message = message
return .none
case .messageFailed(let message):
state.isLoading = false
state.message = message
return .none
default:
appReducer(&state, action)
return .none
}
}For larger apps, keep reducers small and compose them:
let appReducer = combineReducers(
counterReducer,
formReducer
)For production, you can use the standard Store. For development, use TimeTravelStore to enable history tracking:
import SwiftUI
import SwiftState
@main
struct MyApp: App {
// Enable time travel tracking in debug mode with custom logging middleware
@StateObject private var store = TimeTravelStore(
initialState: AppState(),
effectReducer: appEffectReducer,
middlewares: [createLoggerMiddleware()]
)
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}Access the store using @EnvironmentObject and dispatch actions inside SwiftUI buttons. Add TimeTravelDebuggerView as an overlay in debug builds:
struct ContentView: View {
@EnvironmentObject var store: TimeTravelStore<AppState>
var body: some View {
ZStack {
VStack(spacing: 20) {
Text("Counter: \(store.state.counter)")
.font(.largeTitle)
HStack(spacing: 16) {
Button("Decrement") {
store.dispatch(AppAction.decrement)
}
Button("Increment") {
store.dispatch(AppAction.increment)
}
}
TextField(
"Type something...",
text: store.binding(\.textInput, action: AppAction.changeText)
)
.textFieldStyle(.roundedBorder)
.padding()
}
#if DEBUG
// Floating, draggable glassmorphic debugger panel!
TimeTravelDebuggerView(store: store)
#endif
}
}
}The base class managing the state.
state: The read-only state.dispatch(action): Dispatches an action.binding(_:action:): Creates a SwiftUIBindingthat reads from state and dispatches an action when written.
Toggle(
"Notifications",
isOn: store.binding(\.notificationsEnabled, action: SettingsAction.setNotificationsEnabled)
)Use combineReducers to run focused reducers in order:
let reducer = combineReducers(profileReducer, settingsReducer, feedReducer)Use pullback to lift a local reducer into parent state:
let appReducer = combineReducers(
pullback(profileReducer, state: \.profile),
pullback(settingsReducer, state: \.settings)
)Effect reducers can be composed the same way:
let appEffectReducer = combineEffectReducers(
pullback(profileEffectReducer, state: \.profile),
pullback(settingsEffectReducer, state: \.settings)
)Represents asynchronous work that can dispatch actions back into the store.
.none: No asynchronous work..run { dispatch in ... }: Runs async work and dispatches follow-up actions..merge(...): Runs multiple returned effects in order.
Extends Store to capture history entries.
undo(): Steps back in time.redo(): Steps forward in time.jump(to: Int): Jumps to a specific history state.clearHistory(): Clears recorded history while keeping the current state as the new initial entry.history: Array of all recorded states.actionHistory: Array of actions leading to states.historyEntries: Combined timeline entries withindex,state, and optionalaction.maxHistoryLimit: The state history cap. Values lower than1are safely clamped.canUndo/canRedo: Control status flags.
SwiftState records only actions that actually change the state, so ignored actions do not clutter the debugger timeline:
store.dispatch(AppAction.increment) // recorded
store.dispatch(AppAction.noop) // not recorded if state stays equal
for entry in store.historyEntries {
if entry.isInitialState {
print("Initial:", entry.state)
} else {
print("#\(entry.index)", entry.action!, entry.state)
}
}Represents a readable timeline item.
index: The state position in the timeline.state: The captured state at that position.action: The action that produced the state, ornilfor the initial state.isInitialState: Convenience flag for the first entry.
A built-in middleware printing beautiful emojis, execution time, action name, and old/new state details to the console during development.
SwiftState is released under the MIT License. See LICENSE for details.