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:
PersistStateMiddlewarepersists and restores the application state between sessions.PrintActionMiddlewareprints 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