TCA and State Chart

TCA seems to be the perfect fit for this and we should be able to declare a state chart and generate the reducers. We can also generate testing plans based on the shortest paths from the test model's initial state to every other reachable state.

Put simply, a statechart is a beefed up state machine. The beefing up solves a lot of the problems that state machines have, especially state explosion that happens as state machines grow. One of the goals of this site is to help explain what statecharts are and how they are useful.

It would be also possible to display visually the state chart while using the app or the simulator (by connecting to external display).

Since the behaviour of a component is extracted into the statechart, it is relatively easy to make rather big changes to the behaviour of the component, compared to a component where the behaviour is embedded alongside the business logic.

Ian Horrocks, describes maintainability too:

It is difficult to measure maintainability, but it is worth making the following points. Changes to statechart modules were much quicker and easier to make than changes to other modules. The maintainability of statechart-constructed modules remained constant. There was no deterioration in the quality of the code, despite several significant changes to the designs. Three modules that were constructed using a bottom-up approach were rewritten using the statechart approach because the required user interface was too complicated to develop. No statechart module needed to be rewritten.

Ian Horrocks, Constructing the User Interface with Statecharts, page 200

Here are some references:

4 Likes

This is a great intro on the topic by David Khourshid.

1 Like

I just rewrote one of the examples from David in Swift using TCA:

Here is a simple TCA program:

import ComposableArchitecture
import Combine

struct ExampleState {
  enum Status {
    case reading
    case editing
  }
  
  var status: Status
  var value: String
  
  init(
    status: ExampleState.Status = .reading,
    value: String
  ) {
    self.status = status
    self.value = value
  }
}

enum ExampleAction {
  case edit
  case cancel
  case commit(String)
}

struct ExampleEnvironment { }

var exampleReducer = Reducer<ExampleState, ExampleAction, ExampleEnvironment> { state, action, _ in
  switch action {
  case .edit:
    state.status = .editing
    return .none
    
  case .cancel:
    state.status = .reading
    return .none
    
  case let .commit(value):
    state.value = value
    state.status = .reading
    return .none
  }
}

Instead of switching on action, we check in which state we are and then only allow actions that are valid for the current state:

var exampleFiniteReducer = Reducer<ExampleState, ExampleAction, ExampleEnvironment> { state, action, _ in
  switch state.status {
  case .reading:
    switch action {
    case .edit:
      state.status = .editing
      return .none
    
    default:
      return .none
    }
    
  case .editing:
    switch action {
    case .cancel:
      state.status = .reading
      return .none
      
    case let .commit(value):
      state.value = value
      state.status = .reading
      return .none
      
    default:
      return .none
    }
  }
}
2 Likes

It seems like you're treating state.status as one axis and action as the other axis in a 2D table of, uh, actions:

reading editing
edit enter editing ignore
cancel ignore enter reading
commit ignore update value; enter reading

Recognizing that, maybe it would be better to bundle both axes into a tuple and use a single switch statement. Matching on a tuple makes the code more compact and reduces nesting. Furthermore, because we're using enums, we can rely on exhaustiveness checking rather than a default clause to ensure that we've considered every combination, which will be great when we add more states or actions.

var exampleFiniteReducer = Reducer<ExampleState, ExampleAction, ExampleEnvironment> { state, action, _ in
    switch (state.status, action) {
    case (.reading, .edit):
        state.status = .editing

    case (.editing, .cancel):
        state.status = .reading

    case (.editing, .commit(let value)):
        state.value = value
        state.status = .reading

    case (.reading, .cancel),
        (.reading, .commit(_)),
        (.editing, .edit):
        break
    }

    return .none
}
6 Likes

Amazing! now it just looks exactly like a state chart!!

1 Like

Here is another example:

let syncContactsMachine = Reducer<ContactsState, ContactsAction, ContactsEnvironment> { state, action, environment in
  struct ContactsClientID: Hashable {}

  switch state.syncStatus {
  case .stopped:
    switch action {
    case .binding(\.syncRequested):
      guard state.syncRequested else { return .none }
      return .init(value: .sync(.stopped(.start)))
      
    case .sync(.stopped(.start)):
      return .concatenate(
        .init(value: .binding(.set(\.syncStatus, .idle))),
        
        environment.client.create(
          libPhoneNumber: environment.libPhoneNumber,
          on: environment.syncBackgroundQueue
        )
        .receive(on: environment.mainQueue)
        .map { ContactsAction.client($0) }
        .eraseToEffect()
        .cancellable(id: ContactsClientID())
      )
      
    case .binding:
      return .none
      
    default:
      break
    }
    
  case .idle:
    switch action {
    case .binding(\.syncRequested), .binding(\.syncStatus):
      guard state.syncRequested else { return .none }
      return .init(value: .sync(.idle(.sync)))
      
    case .sync(.idle(.sync)):
      return environment.client.syncContacts(
        on: environment.syncBackgroundQueue,
        batchSize: 250
      )
      .receive(on: environment.mainQueue)
      .fireAndForget()
      
    case let .client(.syncContactsDidStart(progress)):
      return .merge(
        .init(value: .binding(.set(\.syncRequested, false))),
        .init(value: .binding(.set(\.syncStatus, .syncing))),
        .init(value: .binding(.set(\.syncProgress, progress)))
      )
      
    case .binding:
      return .none
      
    default:
      break
    }
    
  case .syncing:
    switch action {
    case let .client(.syncContactsDidEncounterError(error)):
      return .init(value: .binding(.set(\.syncStatus, .error(error))))
      
    case .client(.syncContactsdidComplete):
      return .merge(
        .init(value: .binding(.set(\.syncStatus, .idle))),
        .init(value: .binding(.set(\.syncProgress, nil)))
      )
      
    case .binding:
      return .none
      
    default:
      break
    }
    
  case .error:
    switch action {
    case .binding:
      return .none
      
    default:
      break
    }
  }
  print(state.status, state.syncStatus, "Unexpected Action", action)
  return .none
}
}

Recognizing that, maybe it would be better to bundle both axes into a tuple and use a single switch statement. Matching on a tuple makes the code more compact and reduces nesting.

Thats an interesting topic @ariana . Not entirely sure if I get it right… The gist of it is that modeling state/actions as a proper statechart - so relying on enums rather than a set of Bools, Optionals and similar (that do not necessarily communicate the high-level design or can be vague/absurd) - leads to better testability, communicability, developability and so on? Am I missing something?

1 Like

Thank you @ariana for bringing this topic up and also for the link to David Khourshids video. I've actually designed state machines for UI state when I was asked by designers which edge cases a UI they've designed could have. So, after doing this a few times (with huge success), the natural question I had was if this is maybe something developers do in common in larger companies.

One thing I've learned from the talk is Hierarchical Finite State Machines (HFSM), which was one of the issues I've come across when trying to build larger functionality into non-hierarchical state machines, which lead to the State Explosion already mentioned by David. They seem to solve my issues there. :100:

And I also totally agree that designing the state in TCA using HFSMs makes a lot of sense and goes in line with the composability of TCA, where each "box" / hierarchy in the state machine can be represented as a separate set of store + reducer combination.

The big question for me is though: How exactly could we make use of HFSMs in TCA?

Here's some more specific questions that come to my mind right away:
Should there be a tool that can read Swift code and generate state machines for visual analysis?
Or should it be the other way around and a visual state machine get converted into Swift code with stubs? I guess both directions together would make most sense as this makes it possible to keep the two in sync with each other at all times. Also, at which place exactly should this be implemented, right within TCA or as a community extension kind of library? Is there a standard format for HFSMs to store them in? Are there already (open source) tools for creating HFSMs visually that we can make use of?

So, you see that I'm quite interested in this topic but also have a lot of questions. I'd love to (help) building tools around this idea and exploring how we can further simplify the process of reasoning about different states in our applications.

1 Like