SwiftDux

2.0.1

Predictable state management for SwiftUI applications.
StevenLambion/SwiftDux

What's New

v2.0.1

2020-11-30T00:04:26Z

Fixes

  • The View.onAppear(dispatch:) method caused duplicate views. #54
  • RunnableActions were incorrectly dispatched to the store's reducer.

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

Description

  • Swift Tools 5.3.0
View More Packages from this Author

Dependencies

Last updated: Sun Oct 20 2024 03:05:12 GMT-0900 (Hawaii-Aleutian Daylight Time)