Skip to content

ChiefVenzox/SwiftState

Repository files navigation

SwiftState 🚀

Swift Platforms License

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.


Key Features

  • 🏎️ Modern Swift Concurrency: Native thread-safety using @MainActor and 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 Equatable checks).

Architectural Data Flow

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
Loading

Installation

Swift Package Manager (SPM)

Add SwiftState to your project dependencies with Xcode:

  1. Open File > Add Package Dependencies....
  2. Paste https://github.com/ChiefVenzox/SwiftState.git.
  3. Choose Branch and enter main to use the latest SwiftState APIs.
  4. Add the SwiftState product to your app target.
  5. Add the SwiftStateNetwork product 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

SwiftStateNetwork is an optional module that integrates async HTTP requests into SwiftState's Action -> Middleware -> Reducer -> State flow.

import SwiftState
import SwiftStateNetwork

Basic HTTP Usage

Create 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")
)

Middleware Example

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:)

SwiftUI Example

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) }
                ))
            }
        }
    }
}

Testing With MockHTTPClient

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.


Quick Start Guide

1. Define State and Actions

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)
}

2. Create a Reducer

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
)

3. Initialize the Store

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)
        }
    }
}

4. Wire Up Your Views & Add Debugger

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
        }
    }
}

Core API Reference

Store<S>

The base class managing the state.

  • state: The read-only state.
  • dispatch(action): Dispatches an action.
  • binding(_:action:): Creates a SwiftUI Binding that reads from state and dispatches an action when written.
Toggle(
    "Notifications",
    isOn: store.binding(\.notificationsEnabled, action: SettingsAction.setNotificationsEnabled)
)

Reducer Composition

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)
)

Effect

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.

TimeTravelStore<S>

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 with index, state, and optional action.
  • maxHistoryLimit: The state history cap. Values lower than 1 are 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)
    }
}

TimeTravelHistoryEntry<S>

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, or nil for the initial state.
  • isInitialState: Convenience flag for the first entry.

createLoggerMiddleware()

A built-in middleware printing beautiful emojis, execution time, action name, and old/new state details to the console during development.


License

SwiftState is released under the MIT License. See LICENSE for details.

About

A lightweight SwiftUI state management framework with async effects, composable reducers, middleware, bindings, and a time-travel debugger.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages