Skip to content

Repository files navigation

swift-store

Version Swift Platforms CI License

A lightweight, modern Redux-style store for Swift — built with Swift Concurrency in mind.

swift-store provides a minimal and predictable state container inspired by Redux, without the complexity of larger frameworks. It embraces async/await, uses actor isolation for thread safety, and integrates naturally with SwiftUI.

In less than 30 lines of code

struct CounterState: AppState {
  var count = 0
}

enum CounterAction: AppAction {
  case increment
}

let reducer: Reducer<CounterState> = { state, action in
  var newState = state
  switch (action) {
    case CounterAction.increment:
        newState.count = newState.count + 1

    default:
        reutrn state
  }
  return newState
}

let store = Store(
  initialState: CounterState(),
  reducer: reducer
)

Task {
  await store.dispatch(CounterAction.increment)
}

ToC

Why swift-store?

  • 🧠 Simple mental model — Actions → Reducers → State
  • ⚡️ Swift Concurrency-first — built around async/await
  • 🔒 Thread-safe by design — internal actor guarantees ordered, race-free updates
  • 🧩 Composable — split state and reducers as your app grows
  • 🪶 Lightweight — no macros, no codegen, no heavy abstractions

Not a framework

Unlike larger solutions like TCA, swift-store intentionally stays minimal:

  • No custom DSLs
  • No opinionated architecture layers
  • No boilerplate-heavy patterns

You bring your own structure — swift-store just gives you the core primitives.

When to use it

swift-store is a great fit if you want:

  • predictable state updates
  • async side effects (Thunks) without magic
  • a small, understandable core you can extend yourself

If you’re building a SwiftUI app and want Redux-style state without the weight, this is for you.

Usage

Installation

Add via Swift Package Manager:

https://github.com/haensl/swift-store

Import the module

import SwiftStore

Redux: Mental Model

  • Action → describes what happened
  • Reducer → computes new state
  • Thunk → performs async work
  • Middleware → reacts to actions and state changes
  • Store → orchestrates everything

Design Principles

  • State is the single source of truth
  • Reducers are pure and synchronous
  • Side effects live in Thunks or Middleware
  • Updates flow in one direction: Action → Reducer → State

Concurrency requirements

  • All state (AppState, Middlewares, Reducers, ...) and captured values should conform to Sendable in order to ensure thread safety.
  • Reducers must be synchronous and side-effect free.
  • dispatch is async due to internal actor - call it from a Task or async context.

Example: Define your state and reducer(s)

Use the AppState, AppAction, Middleware and Thunk to model your application state and business logic. Let's walk through an example:

Define your root state. It can be composed of sub-states produced by other reducers:

struct RootState: AppState {
  var user: UserState

  init() {
    self.user = UserState()
  }
}

Define your root reducer. It might just invoke sub-reducers:

@Sendable
func RootReducer(state: RootState, action: AppAction) -> RootState {
  // Create a mutable copy
  var newState = state

  // Apply your sub-reducers
  newState.user = UserReducer(state: newState.user, action: action)

  return newState
}

Define your sub state(s):

struct UserState: AppState {
  var name: String = ""

  var updateInProgress: Bool = false

  // ...
}

Define actions to manipulate UserState:

enum UserAction: AppAction {
  /// Sets ``UserState/name``
  case setName(String)

  /**
   Signals that a PATCH at the Users API has finished
   - Parameter : Potential error that occurred.
   */
  case updateFinished(Error? = nil)

  /// Signals that a PATCH at the Users API is ongoing
  case updateInProgress
}

Handle the actions in the Reducer:

@Sendable
func UserReducer(state: UserState, action: AppAction) -> UserState {
  // Create a mutable copy
  var newState = state

  switch (action) {
    case UserAction.setName(let name):
      newState.name = name

    case UserAction.updateFinished(let error):
      if let error {
        Log.error("User profile update failing \(String(describing: error))")
      }
      newState.updateInProgress = false

    case UserAction.updateInProgress:
      newState.updateInProgress = true

    default:
      // If nothing changed -> return original state
      return state
  }

  return newState
}

Define asynchronous operations (thunks):

struct UserThunk {
  /**
   Updates the user profile.

   Performs the given patch at the Users API.

   - Parameter patch: The ``User`` patch to apply.

   - Returns: A ``Thunk`` that updates ``UserState``
   */
  static func update(patch: User) -> Thunk<RootState> {
    return { [patch] store in
      // Get current state
      let state = await store.getState()

      if (state.user.updateInProgress) {
        Log.info("Update in progress.")
        return
      }

      await store.dispatch(UserAction.updateInProgress)

      do {
        try await UsersService.shared.update(patch: patch)
        await store.dispatch(UserAction.updateFinished(nil))
      } catch {
        await store.dispatch(UserAction.updateFinished(error))
      }
    }
  }
}

Use middlewares to drive your application:

// All middlewares follow this signature
@Sendable
func UserMiddleware(
  // Store to dispatch to
  store: Store<RootState>,
  // Action that lead to current state
  action: AppAction,
  // Current application state
  state: RootState,
  // State before action was applied
  previous: RootState
) async -> Void {
  switch (action) {
    // Load user profile on sign in
    case UserAction.signInSuccess:
      await store.dispatch(UserThunk.load())

    // ...
  }
}

Instantiate your Store

Instantiate your app's store at an appropriate place for your platform.

Example: SwiftUI

Use your App to hold the store and pass it to your Views:

import SwiftUI
import SwiftStore

@main
struct MyApp: App {
  @StateObject private var store = Store<RootState>(
    initialState: RootState(),
    reducer: RootReducer.self,
    middlewares: [
      UserMiddleware
    ]
  )

  var body: some Scene {
    WindowGroup {
      NavigationStack {
        // ...
      }
      .environmentObject(store) // Pass store to view hierarchy
    }
  }
}

Use it in your views

Use the store to drive your views:

import SwiftUI

struct OnboardingNameView: View {
  @EnvironmentObject var store: Store<RootState> // Store passed down via environment

  // ...
  private var username: Binding<String> {
    .init(
      get {
        store.state.user.name
      }
      set { newValue in
        Task { [newValue] in
          await store.dispatch(UserAction.setName(newValue))
        }
      }
    )
  }

  var body: some View {
    VStack {
      TextField(
        "",
        text: username,
        prompt: Text("Please enter your name.")
      )

      // ...
    }
  }
}

Gotchas

  • Reducers must be pure — no async work or side effects.
  • State should be value types (struct) for best results.
  • Use Thunks for initiating async work (API calls, workflows)
  • Use Middleware for reacting to actions (logging, chaining, orchestration)
  • dispatch is async - the Store is actor-isolated and guarantees ordered, thread-safe updates.
  • Middlewares are executed in the order they are provided.

API

AppAction

AppAction is a protocol type to mark your types as Redux store actions:

// Sendable via AppAction protocol
enum UserAction: AppAction {
  case setName(String)

  case updateFinished(Error? = nil)

  case updateInProgress
}

Actions are dispatched to the Store:

await store.dispatch(UserAction.updateInProgress)

It is common practice to use enum types as AppAction, though other types are possible.

AppState

AppState is a protocol type to mark your types as Redux state:

// Sendable via AppState protocol
struct UserState: AppState {
  var name: String = ""
  var updateInProgress: Bool = false
}

It is common to use struct types for AppState since value types harmonize with redux principles, but other types are possible as long as they adhere to @Sendable.

Middleware

Middleware is a function type. It defines the signature for your middlewares:

typealias Middleware<T: AppState> = @Sendable (
  _ store: Store<T>,
  _ action: AppAction,
  _ state: T,
  _ previous: T
) async -> Void

Example:

// Sendable via Middleware protocol
let UserMiddleware: Middleware<RootState> = { store, action, state, previous in
  switch (action) {
      // Load user profile on sign in
      case UserAction.signInSuccess:
        await store.dispatch(UserThunk.load())

      // ...
  }
}

This is equivalent to writing:

@Sendable
func UserMiddleware(store: Store<RootState>, action: AppAction, state: RootState, previous: RootState) async -> Void {
  // ...
}

Middlewares are invoked with the store, the current action, the current state and the previous state (before action was applied). Middlewares are awaited sequentially after each action is reduced.

Reducer

Reducer is a function type and defines the signature of your reducers:

typealias Reducer<T: AppState> = @Sendable (T, AppAction) -> T

Example:

// Sendable via Reducer protocol
let UserReducer: Reducer = { state, action in
  var newState = state

  switch (action) {
    case UserAction.setName(let name):
      newState.name = name

    default:
      return state
  }

  return newState
}

This is equivalent to writing:

@Sendable
func UserReducer(state: UserState, action: AppAction) -> UserState {
  // ...
}

Attention: Avoid side-effects in reducers. Use thunks or middleware instead.

Store

A simple redux store implementation.

Store<T: AppState> is a @MainActor class backed by an internal actor for thread-safe mutations. It manages a composable AppState and allows for dispatching of actions and thunks. Add Middlewares to your store to host your business logic.

import SwiftUI
import SwiftStore

@main
struct MyApp: App {
    @StateObject private var store = Store<RootState>(
      initialState: RootState(),
      reducer: RootReducer.self,
      middlewares: [
        UserMiddleware
      ]
    )

    var body: some Scene {
      WindowGroup {
        NavigationStack {
          // ...
        }
        .environmentObject(store) // Pass store to view hierarchy
      }
    }
}

Store.state

The current root state.

@Published var state: T

The published current root T: AppState. For @MainActor contexts like SwiftUI Views, this is the primary access path to current application state:

import SwiftUI

struct OnboardingNameView: View {
  @EnvironmentObject var store: Store<RootState> // Store passed down via environment

  // ...
  private var username: Binding<String> {
    .init(
      get {
        store.state.user.name // Access current state
      }
      set { newValue in
        Task { [newValue] in
          await store.dispatch(UserAction.setName(newValue))
        }
      }
    )
  }

  var body: some View {
    VStack {
      TextField(
        "",
        text: username,
        prompt: Text("Please enter your name.")
      )

      // ...
    }
  }
}

Store.init(initialState: T: AppState, reducer: Reducer, middlewares: [Middleware])

Creates a new store.

init(
  initialState: T,
  reducer: @escaping Reducer<T>,
  middlewares: [Middleware<T>] = []
)
Parameters

initialState: T

Provide the initial state for the store. T must be an AppState.

reducer: Reducer

The root reducer for this store. Reducers can be composed of sub-reducers as shown in the example above.

middlewares: [Middleware]

The middlewares to run after each state mutation. Middlewares are awaited sequentially.

Store.dispatch(_ action: AppAction)

Dispatches an AppAction to the store. Dispatches are processed in order (FIFO) and are thread-safe.

func dispatch(_ action: AppAction) async

The store is backed by its own actor to ensure thread safety. You therefore need to await dispatching:

await store.dispatch(UserAction.setName("new name"))

Store.dispatch(_ thunk: Thunk)

Dispatches a Thunk to the store. Thunks can perform side-effects and asynchronous work.

func dispatch(_ thunk: Thunk<T>) async

The store is backed by it's own actor to ensure thread safety. You therefore need to await dispatching:

await store.dispatch(UserThunk.update(patch: patch))

Store.getState() -> T

Returns the current application state.

func getState() -> T

getState() is @MainActor-isolated. Access it directly from SwiftUI Views, or await it from async contexts like Thunks.

// e.g. in your thunk:
let state = await store.getState()

Store.postpone(tag: String, _ thunk: Thunk)

Postpone a thunk. Sets the given thunk aside for later execution under the given tag. Preserves order (FIFO).

func postpone(tag: String, _ thunk: @escaping Thunk<T>) async
Parameters

tag: String

A tag to associate with this thunk. This is useful to create execution buckets that can later be run via runPostponed().

thunk: Thunk<T>

The Thunk to postpone.

Store.runPostponed(tag: String)

Run postponed thunks associated with the given tag.

func runPostponed(tag: String) async
  • Running postponed thunks removes them from queue.
  • Postponed thunks are dispatched in the order they were postponed (FIFO).
Parameters

tag: String

The tag of the thunks to run.

Thunk

Thunk is a type of action that manipulates the state by dispatching other actions. Use it for your asynchronous work, e.g. API requests, etc.

typealias Thunk<T: AppState> = @Sendable (Store<T>) async -> Void

Example:

struct UserThunk {
  /**
   Updates the user profile.

   Performs the given patch at the Users API.

   - Parameter patch: The ``User`` patch to apply.

   - Returns: A ``Thunk`` that updates ``UserState``
   */
  static func update(patch: User) -> Thunk<RootState> {
    return { [patch] store in // Sendable via Thunk protocol
      // Get current state
      let state = await store.getState()

      if (state.user.updateInProgress) {
        Log.info("Update in progress.")
        return
      }

      await store.dispatch(UserAction.updateInProgress)

      do {
        try await UsersService.shared.update(patch: patch)
        await store.dispatch(UserAction.updateFinished(nil))
      } catch {
        await store.dispatch(UserAction.updateFinished(error))
      }
    }
  }
}

Thunks are typically created from parameterizable functions and dispatched to the store:

await store.dispatch(UserThunk.update(patch: User(name: "new name")))

License

MIT License

About

A lightweight Redux-style state container for Swift, built with async/await and actors.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages