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:

2 Likes

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

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

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

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

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.

Terms of Service

Privacy Policy

Cookie Policy