Predictable state management for SwiftUI applications.
SwiftDux is a state container inspired by Redux and built on top of Combine and SwiftUI. It helps you write applications with predictable, consistent, and highly testable logic using a single source of truth.
- Xcode 12+
- Swift 5.3+
- iOS 14+, macOS 11.0+, tvOS 14+, or watchOS 7+
Search for SwiftDux in Xcode's Swift Package Manager integration.
import PackageDescription
let package = Package(
dependencies: [
.Package(url: "https://github.com/StevenLambion/SwiftDux.git", from: "2.0.0")
]
)
Take a look at the Todo Example App to see how SwiftDux works.
SwiftDux helps build SwiftUI-based applications around an elm-like architecture using a single, centralized state container. It has 4 basic constructs:
- State - An immutable, single source of truth within the application.
- Action - Describes a single change of the state.
- Reducer - Returns a new state by consuming the previous one with an action.
- View - The visual representation of the current state.
The state is an immutable structure acting as the single source of truth within the application.
Below is an example of a todo app's state. It has a root AppState
as well as an ordered list of TodoItem
objects.
import SwiftDux
typealias StateType = Equatable & Codable
struct AppState: StateType {
todos: OrderedState<TodoItem>
}
struct TodoItem: StateType, Identifiable {
var id: String,
var text: String
}
An action is a dispatched event to mutate the application's state. Swift's enum type is ideal for actions, but structs and classes could be used as well.
import SwiftDux
enum TodoAction: Action {
case addTodo(text: String)
case removeTodos(at: IndexSet)
case moveTodos(from: IndexSet, to: Int)
}
A reducer consumes an action to produce a new state.
final class TodosReducer: Reducer {
func reduce(state: AppState, action: TodoAction) -> AppState {
var state = state
switch action {
case .addTodo(let text):
let id = UUID().uuidString
state.todos.append(TodoItemState(id: id, text: text))
case .removeTodos(let indexSet):
state.todos.remove(at: indexSet)
case .moveTodos(let indexSet, let index):
state.todos.move(from: indexSet, to: index)
}
return state
}
}
The store manages the state and notifies the views of any updates.
import SwiftDux
let store = Store(
state: AppState(todos: OrderedState()),
reducer: AppReducer()
)
window.rootViewController = UIHostingController(
rootView: RootView().provideStore(store)
)
SwiftDux supports middleware to extend functionality. The SwiftDuxExtras module provides two built-in middleware to get started:
PersistStateMiddleware
persists and restores the application state between sessions.PrintActionMiddleware
prints out each dispatched action for debugging purposes.
import SwiftDux
let store = Store(
state: AppState(todos: OrderedState()),
reducer: AppReducer(),
middleware: PrintActionMiddleware())
)
window.rootViewController = UIHostingController(
rootView: RootView().provideStore(store)
)
You may compose a set of reducers, actions, or middleware into an ordered chain using the '+' operator.
// Break up an application into smaller modules by composing reducers.
let rootReducer = AppReducer() + NavigationReducer()
// Add multiple middleware together.
let middleware =
PrintActionMiddleware() +
PersistStateMiddleware(JSONStatePersistor()
let store = Store(
state: AppState(todos: OrderedState()),
reducer: reducer,
middleware: middleware
)
The ConnectableView
protocol provides a slice of the application state to your views using the functions map(state:)
or map(state:binder:)
. It automatically updates the view when the props value has changed.
struct TodosView: ConnectableView {
struct Props: Equatable {
var todos: [TodoItem]
}
func map(state: AppState) -> Props? {
Props(todos: state.todos)
}
func body(props: OrderedState<Todo>): some View {
List {
ForEach(todos) { todo in
TodoItemRow(item: todo)
}
}
}
}
Use the map(state:binder:)
method on the ConnectableView
protocol to bind an action to the props object. It can also be used to bind an updatable state value with an action.
struct TodosView: ConnectableView {
struct Props: Equatable {
var todos: [TodoItem]
@ActionBinding var newTodoText: String
@ActionBinding var addTodo: () -> ()
}
func map(state: AppState, binder: ActionBinder) -> OrderedState<Todo>? {
Props(
todos: state.todos,
newTodoText: binder.bind(state.newTodoText) { TodoAction.setNewTodoText($0) },
addTodo: binder.bind { TodoAction.addTodo() }
)
}
func body(props: OrderedState<Todo>): some View {
List {
TextField("New Todo", text: props.$newTodoText, onCommit: props.addTodo)
ForEach(todos) { todo in
TodoItemRow(item: todo)
}
}
}
}
An ActionPlan
is a special kind of action that can be used to group other actions together or perform any kind of async logic outside of a reducer. It's also useful for actions that may require information about the state before it can be dispatched.
/// Dispatch multiple actions after checking the current state of the application.
let plan = ActionPlan<AppState> { store in
guard store.state.someValue == nil else { return }
store.send(actionA)
store.send(actionB)
store.send(actionC)
}
/// Subscribe to services and return a publisher that sends actions to the store.
let plan = ActionPlan<AppState> { store in
userLocationService
.publisher
.map { LocationAction.updateUserLocation($0) }
}
You can access the ActionDispatcher
of the store through the environment values. This allows you to dispatch actions from any view.
struct MyView: View {
@Environment(\.actionDispatcher) private var dispatch
var body: some View {
MyForm.onAppear { dispatch(FormAction.prepare) }
}
}
If it's an ActionPlan that's meant to be kept alive through a publisher, then you'll want to send it as a cancellable. The action below subscribes to the store, so it can keep a list of albums updated when the user applies different queries.
extension AlbumListAction {
var updateAlbumList: Action {
ActionPlan<AppState> { store in
store
.publish { $0.albumList.query }
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.map { AlbumService.all(query: $0) }
.switchToLatest()
.catch { Just(AlbumListAction.setError($0) }
.map { AlbumListAction.setAlbums($0) }
}
}
}
struct AlbumListContainer: ConnectableView {
@Environment(\.actionDispatcher) private var dispatch
@State private var cancellable: Cancellable? = nil
func map(state: AppState) -> [Album]? {
state.albumList.albums
}
func body(props: [Album]) -> some View {
AlbumsList(albums: props).onAppear {
cancellable = dispatch.sendAsCancellable(AlbumListAction.updateAlbumList)
}
}
}
The above can be further simplified by using the built-in onAppear(dispatch:)
method instead. This method not only dispatches regular actions, but it automatically handles cancellable ones. By default, the action will cancel itself when the view is destroyed.
struct AlbumListContainer: ConnectableView {
func map(state: AppState) -> [Album]? {
Props(state.albumList.albums)
}
func body(props: [Album]) -> some View {
AlbumsList(albums: props).onAppear(dispatch: AlbumListAction.updateAlbumList)
}
}
To preview a connected view by itself use the provideStore(_:)
method inside the preview.
#if DEBUG
public enum TodoRowContainer_Previews: PreviewProvider {
static var store: Store<TodoList> {
Store(
state: TodoList(
id: "1",
name: "TodoList",
todos: .init([
Todo(id: "1", text: "Get milk")
])
),
reducer: TodosReducer()
)
}
public static var previews: some View {
TodoRowContainer(id: "1")
.provideStore(store)
}
}
#endif