ForEachStore equivalent for UIKit?

I'm playing with the Composable Architecture, and I want to include it in a UIKit application, not using SwiftUI. I am having trouble wrapping my head around scoping a store of an array of items to a store of a single item.

My model, actions and reducers:

import ComposableArchitecture
import Foundation

// MARK: Single Todo

struct Todo: Equatable {
  var id: Int
  var text: String
}

enum TodoAction: Equatable {
  case updateTodo(text: String)
}

struct TodoEnvironment {}

let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment> { todo, action, _ in
  switch action {
    case .updateTodo(let text):
      todo.text = text
    return .none
  }
}

// MARK: AppState (all todos)

struct AppState: Equatable {
  var todos: [Todo] = []
}

enum AppAction: Equatable {
  case setTodos([Todo])
  case todo(id: Int, action: TodoAction)
}

struct AppEnvironment {
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  Reducer { state, action, environment in
    switch action {
      case .setTodos(let todos):
        state.todos = todos
        return .none

      case .todo:
        return .none
    }
  },
  todoReducer.forEach(
    state: \.todos,
    action: /AppAction.todo(id:action:),
    environment: { _ in TodoEnvironment() }
  )
)

And a simple ViewController. Don't mind the forced unwrapped optionals, just a test project and yes, using a storyboard.

import UIKit
import Combine
import ComposableArchitecture

class ViewController: UIViewController {
  @IBOutlet private var tableView: UITableView!

  private var subscriptions = Set<AnyCancellable>()
  private var store: Store<AppState, AppAction>!
  private var viewStore: ViewStore<AppState, AppAction>!

  override func viewDidLoad() {
    store = Store(initialState: AppState(), reducer: appReducer, environment: AppEnvironment())
    viewStore = ViewStore<AppState, AppAction>(store)

    super.viewDidLoad()

    navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Load", style: .done, target: self, action: #selector(load))

    viewStore.publisher.todos.sink { todos in
      print(todos)
      self.tableView.reloadData()
    }.store(in: &subscriptions)
  }

  @objc func load() {
    viewStore.send(.setTodos([Todo(id: 1, text: "Buy milk")]))
  }
}

extension ViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    viewStore.todos.count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let todo = viewStore.todos[indexPath.row]

    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = todo.text
    return cell
  }
}

extension ViewController: UITableViewDelegate {
  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let todo = viewStore.todos[indexPath.row]

    // Create a scoped store for a single todo
    let scopedStore = store.scope(state: { $0.todos }, action: AppAction.todo)

    // scopedStore is now of type Store<[Todo], (Int, TodoAction)>, which is a problem
    let scopedViewStore = ViewStore<Todo, TodoAction>(scopedStore)
    // Cannot convert value of type 'Store<[Todo], (Int, TodoAction)>' to expected argument type 'Store<Todo, TodoAction>'
  }
}

In the didSelectRowAt method I'd want to navigate to another screen, showing (and operating on) a single Todo item. So I'd want to pass it a Store<Todo, TodoAction>, and it can create its own ViewStore. But creating a scoped Store returns Store<[Todo], (Int, TodoAction)>, which is not the correct signature to create a ViewStore<Todo, TodoAction> with.

This is something that ForEachStore handles in SwiftUI, but how I am supposed to handle this in a simple UIKit app? How do I create a store that operates on a single Todo item, and how do I then actually give it that single Todo?

It seems that one of the official examples has the answer I was looking for:

let scopedStore = store.scope(state: { $0.todos[indexPath.row] }, action: { .todo(index: indexPath.row, action: $0) })

(instead of let scopedStore = store.scope(state: { $0.todos }, action: AppAction.todo))

The only thing is that this is index-based, whereas this example uses an ID-based todo action and reducer, which is something that ForEachStore does. It would be great to have something similar for UIKit, so we don't have to rely on passing along an index.

@kevinrenskers I think you want to upgrade your [Todo] array to an IdentifiedArrayOf<Todo>, and then you could subscript via the id:

Thanks, that is helpful.

I can now do something like this:

extension ViewController: UITableViewDelegate {
  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let todo = viewStore.todos[indexPath.row]

    // Create a scoped store for this single todo
    let scopedStore = store.scope(state: { $0.todos[id: todo.id] }, action: { .todo(id: todo.id, action: $0) })
    let scopedViewStore = ViewStore<Todo?, TodoAction>(scopedStore)

    scopedViewStore.send(.updateTodo(text: "Done!"))
  }
}

Just as a quick test, but realistically you can create a new view controller and pass along the scoped ViewStore. And yeah it works, the list actually gets updated as expected. A bit annoying that you now have a ViewStore of an optional Todo? though (of course I can force unwrap $0.todos[id: todo.id] but not sure if that is smart).

By the way, it kinda seems like the whole concept of ViewStore is a bit overkill and useless for UIKit? It doesn't have the SwiftUI problems of automatically subscribing to too much state changes and doing too much work too often? Or am I missing something?

There's also store.ifLet, which evaluates the given block with a safely-unwrapped store.

While the view store solves additional problems for SwiftUI, we find that it generally encourages better encapsulation. For example, in the Tic-Tac-Toe demo there are a few navigation flows:

  • Sign In -> Two-Factor Auth
  • New Game -> Current Game

In both flows, the parent VC ("Sign In" and "New Game") needs a store that can contain child state ("Two-Factor Auth" and "Current Game"), but their view stores are constructed in such a way that they cannot access that state directly, or send it actions.

View stores are always quick to construct if you just want to get something on the screen, but we hope that coming across ViewStore(store) will encourage folks to take an opportunity to factor view state to the bare essentials in each VC.

Terms of Service

Privacy Policy

Cookie Policy