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.
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)
}- 🧠 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
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.
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.
Add via Swift Package Manager:
https://github.com/haensl/swift-store
import SwiftStoreAction→ describes what happenedReducer→ computes new stateThunk→ performs async workMiddleware→ reacts to actions and state changesStore→ orchestrates everything
- 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
- All state (
AppState,Middlewares,Reducers, ...) and captured values should conform toSendablein order to ensure thread safety. - Reducers must be synchronous and side-effect free.
dispatchisasyncdue to internalactor- call it from aTaskorasynccontext.
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 app's store at an appropriate place for your platform.
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 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.")
)
// ...
}
}
}- 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
Middlewarefor reacting to actions (logging, chaining, orchestration) dispatchisasync- theStoreisactor-isolated and guarantees ordered, thread-safe updates.- Middlewares are executed in the order they are provided.
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 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 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 -> VoidExample:
// 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 is a function type and defines the signature of your reducers:
typealias Reducer<T: AppState> = @Sendable (T, AppAction) -> TExample:
// 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.
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
}
}
}The current root state.
@Published var state: TThe 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.")
)
// ...
}
}
}Creates a new store.
init(
initialState: T,
reducer: @escaping Reducer<T>,
middlewares: [Middleware<T>] = []
)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.
Dispatches an AppAction to the store. Dispatches are processed in order (FIFO) and are thread-safe.
func dispatch(_ action: AppAction) asyncThe store is backed by its own actor to ensure thread safety. You therefore need to await dispatching:
await store.dispatch(UserAction.setName("new name"))Dispatches a Thunk to the store. Thunks can perform side-effects and asynchronous work.
func dispatch(_ thunk: Thunk<T>) asyncThe 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))Returns the current application state.
func getState() -> TgetState() 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()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>) asynctag: 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.
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).
tag: String
The tag of the thunks to run.
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 -> VoidExample:
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")))