Skip to content

StevenLambion/SwiftDux

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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