Swiftpack.co - Package - StevenLambion/SwiftDux

SwiftDux

Predictable state management for SwiftUI applications.

Swift Version Platform Versions Github workflow codecov

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.

Installation

Prerequisites

  • Xcode 12+
  • Swift 5.3+
  • iOS 14+, macOS 11.0+, tvOS 14+, or watchOS 7+

Install via Xcode:

Search for SwiftDux in Xcode's Swift Package Manager integration.

Install via the Swift Package Manager:

import PackageDescription

let package = Package(
  dependencies: [
    .Package(url: "https://github.com/StevenLambion/SwiftDux.git", from: "2.0.0")
  ]
)

Demo Application

Take a look at the Todo Example App to see how SwiftDux works.

Getting Started

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.

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
}

Actions

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

Reducers

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

Store

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

Middleware

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

Composing Reducers, Middleware, and Actions

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
)

ConnectableView

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

ActionBinding<_>

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

Action Plans

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

Action Dispatching

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

Previewing Connected Views

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

Github

link
Stars: 117

Used By

Total: 0

Releases

v2.0.0 -

Deprecated

  • MappedDispatch should be replaced with @Environment(.\actionDisaptcher) to be consistent with Apple's usage. #50
  • MappedState should be replaced with ConnectableView or Connector. #50
  • StateType and IdentifiableState are deprecated in favor of having applications define their own protocol to adhere to. Instead of requiring an opinionated state type, the library uses conditional protocol adherences when necessary. #50
  • onAction(perform:) view modifier is deprecated in favor of using middleware. #50

Changes

  • macOS 11.0 is now required. #50
  • Removed ActionType from the Store<_>'s didChange publisher. #48
  • Connector's internal publishing logic has been moved to StateStorable, so it may be reused elsewhere. #50
  • All actions may now be chained instead of just ActionPlans. #51
  • RunnableAction now returns an AnyPublisher<Action, Never> type instead of an AnyCancellable. #51
  • ActionPlan now requires a publisher to return from async blocks instead of providing a completion closure. #52
  • Middleware now return an optional Action instead of calling next(_:) on the StoreProxy<_>. #52

Added

  • Added concrete StorePublisher in place of using AnyPublisher for store changes. #48
  • Added StateStorable protocol so that there's a shared way to extend the Store<> and StoreProxy<> types. #50
  • Added StateStorable.publish(_:) method as a single place to receive an updatable state object. #50

Removed

  • StateBinder is removed. #48
  • Removed next(_:) from StoreProxy<_>. #52
  • Removed the completion closure from ActionSubscriber. #52

Fixed

  • Fixed type inference with onAppear(dispatch:). #50
  • Fixed a circular dependency in the StoreProxy<_> given to middleware. #52
  • Fixed a bug introduced with iOS 14 that may cause a crash related to the onReceive view modifier. #52

v1.2.4 -

Fixed

  • Fixed broken type inference in Swift 5.3. #47

v1.2.3 -

Changed

  • Replaced the requirement for StateType and IdentifiableState by using conditional, granular constraints where needed. #46
  • NoopAnyStore is now a struct. #44
  • Views can now use protocols that the AppState adheres to to retrieve specific parts of the app state. #44

v1.2.1 -

Added

  • View.provideStore(_:as:) To inject a store with a specific type of state. #42

v1.2.0 -

Changed

  • ActionBinder can now bind actions to callable functions. #40, #38
  • Replaced the old internal state connection API with a faster and simpler implementation. #39

Removed

  • Removed Connectable API in favor of ConnectableView due to incompatibilities with the new internal, reference-free implementation. #39

v1.1.0 -

Changed

  • Loosened the adherence to StateType at the Store level. #36
  • Simplified ActionDispatcher API by moving the proxy functionality to OnActionViewModifier. #32
  • Provided a more consistent Store API between reducers and middleware. #33
  • ActionDispatcher can now be called as a function. #32
  • MappedDispatch injects an ActionDispatcher instead of a function. #32
  • Modified the implementation of action plans to make it easier to include 3rd party ones. #36

Added

  • Added ActionBinding<> to replace the Equatable extension on Binding<>. #30

Removed

  • onAppearAsync and onDisappearAsync. #36

v1.0.1 -

Added

  • ConnectableView as a convenient wrapper over Connectable #27

v1.0.0 -

This marks the 1.0.0 release of the library. The API will be stabilized moving forward.

Fixed

  • PersistStateMiddleware would save the state when the StoreAction<_>.reset was dispatched. #24
    • It no longer saves the state when the store is reset.
  • Connectable.updateWhen would stop a view from updating from its own dispatched actions. #24
    • If the method is implemented, both it and any dispatched actions will cause an update.
  • Connectable.updateWhen would fail to work in some cases when the first call returned true. #24
    • Returning true using any possible implementation will now work.

Changed

  • State is now required to be equatable. #26
  • Middleware is now a protocol type. #25
  • StoreProxy.state is now a non-optional type. #24
  • Store, ActionDispatcher, and StatePersistor no longer adhere to Subscriber. #24
  • Store.didChange is now an AnyPublisher<Action, Never>. #24
  • PersistStateMiddleware now debounces for 1 second by default. #24
  • PrintActionMiddleware now accepts a new filter block. #24
  • onAppearAsync and onDisappearAsync are deprecated. #24
    • These were need to workaround a bug in a pervious beta of iOS 13. #24

Added

  • ActionPlan has a new init block that accepts an AnyCancellable to support ActionSubsriber, #24
  • ActionSubscriber to dispatch actions from a publisher. #24
  • PersistSubscriber to persist state from a publisher. #24
  • StoreProxy.done for use in action plans to tell the proxy that it has completed. #24
  • TypedMiddleware and HandleActionMiddleware. #25

v0.12.1 -

Fixed

  • Inconsistency with onMove(_:_:) method of OrderedState<_> #21

Added

  • StateBinder API to create 2-way bindings between a state value and an action. #22

v0.12.0 -

Changes

  • Action plans themselves now trigger the change event from the store. #19

Added

  • API to chain action plans together. #20
  • API to call code blocks when an action plan completes. #20
  • onAppear(dispatch:cancelOnDisappear:) method on View #19

v0.11.3 -

Fixed

  • Manually implemented coding protocol due to bugs with the synthesized version. #15
  • The SwiftExtra module now works on macOS without requiring Catalyst. #16

Technical

  • Added macOS as a testing target. #16

v0.11.2 -

Changes

  • Cleanup to documentation. #13

Added

  • API to cancel publisher based ActionPlans. #14

Technical

  • Code formatting via swift-format. #13
  • Moved documentation to gh-pages branch. #13
  • Set up CI linting and testing via Github workflows. #13
  • Added code coverage reports via codecov. #13

v0.11.1 -

Fixed

  • Fixed type erasing in StateConnectionViewGuard by returning the contents as an optional view rather than using AnyView.

v0.10.0 -

Changes

  • PublishableActionPlan has been merged into ActionPlan.
  • All actions dispatched from a view will update that view. This includes actions dispatched inside an action plan.

Added

  • Middleware API
  • PrintActionMiddleware

v0.11.0 -

Changes

  • Moved PrintActionMiddleware to SwiftDuxExtras
  • StoreProvider view modifier updates its view when a StoreAction<_> Is dispatched.

Added

  • SwiftDuxExtras module
  • Persistence API to SwiftDuxExtras
  • PersistStateMiddleware
  • StoreReducer for store specific actions.
  • "StoreAction<_>.prepare" action fired from the store at initialization.
  • "StoreAction<_>.reset(state:)" action to replace the current state of the store.

v0.9.0 -

Changes

  • The view of a mapped state will wait to render until the state is exists (non-nil). This completes all the previous functionality of the Connector API.
  • The updateWhen argument of View.connect(updateWhen:mapState:) is now optional with a default value.

Fixed

  • Views continue to properly update when dispatching an acton after their parent view has re-rendered.

Removed

  • Connector API has been removed.

v0.8.0 -

Changes

  • Reimplemented property wrapper API now that environment objects are working correctly.
  • Renamed the modifyAction(_:) method to onAction(perform:) to better match the SwiftUI API.
  • An ActionDispatcher no longer returns a publisher when sending an action to simply the API.

Added

  • Connectable and ParameterizedConnectable protocols to replace the Connector API.
  • @MappedState property wrapper to bind a mapped state to a view.
  • @MappedDispatch property wrapper to bind an action dispatcher to a view.

Deprecated

  • Connector API in favor of Connectable

v0.7.1 -

Changes

  • Updated to beta 4 of SwiftUI and Combine.

v0.7.0 -

This version reverts from using property wrappers back to a simplified API due to SwiftUI's inability to handle the creation of bindable objects. It appears that bindable objects of any kind have to be external and/or singleton in nature within SwiftUI. The new Connector API is built around this.

Changes

  • Updated to support beta 3.
  • Removed property wrappers with a new Connector API to externalize and reduce the creation of bindable objects within SwiftUI.
  • Removed withState view modifier in favor of Connector API.
  • Added a new Connector API to generate the mapping of state to a view outside of SwiftUI.
  • Removed modifyAction in favor of using DispatcherProxy directly, so that it can be created outside of SwiftUI.

Known issues

  • NavigationLink has a bug with environment objects. See the "Known Issues" section of the README.

v0.6.0 -

Additions

  • Added onAppearAsync() and onDisappearAsync() to View to help trigger view updates when dispatching actions.

Changes

  • OrderedState<_> is now a RandomAccessCollection, allowing it to be used directly with SwiftUI's list elements.
  • OrderedState<_> now allows subscripting by index or id.

Fixes

  • The state mapping API now assumes SwiftUI Views may still exist shortly after the state itself was removed.

v0.5.0 -

Changes

  • Made major refinements to the property wrapper API.
  • New state mapping API to map the application to a substate, views can focus on just their slice.
  • Store publishes changes on main thread.

v0.4.0 -

Changes

  • Changed action plans from a closure type to structs.
  • ActionDispatcher is simplified to have one method that can received all types of actions (including action plans)
  • Converted API to use the new property wrapper API of Swift when mapping state to a view.

v0.3.0 -

Changes

  • Added Store<>.connect(:, wrapper:) to update views using a mapping of the application's state as an alternative to updating off of an action type.
  • Hooked up dispatcher proxying for connect functions.

v0.2.0 -

Changes

  • Added a new StoreActionDispatcher to separate the store from action dispatching. This allows the ability to proxy, modify, and monitor actions sent upstream.
  • Added View.provideStore(_:) modifier to inject an initial store and dispatcher object into the view environment.
  • Cleaned up and refined APIs
  • Added and cleaned up documentation
  • Reducer.reduceAny(state:action:) will always dispatch the action to Reducer.reduceNext(state:action:)