The Composable Architecture(TCA)で再利用可能なコンポーネントを作る方法

f:id:bamboohero:20210906022107p:plain

TCAのサンプルの中に Reusable Favoriting Componentというものがあります。

「何かをFavoritingする」という機能を提供する、汎用的なコンポーネントの実装例です。

f:id:bamboohero:20210904174141p:plain:w300

f:id:bamboohero:20210904174056p:plain:w300


Getting Startedのサンプルと比べて若干ReducerやStateの書き方が違うので混乱するんですが、よくよく見てみると基本的な構造はCounterサンプルなどと同じでした。

bamboo-hero.com

bamboo-hero.com


ちょっと個人的なメモみたいな記事になってしまいますが、要点を整理してみたので参考にしてみてください。



Favoriting Componentの構造

TCAは基本的にState、Action、Environment、Reducerの4つでFeature(機能)を構成し、それをビューと組み合わせます。

このサンプルのREADMEで「再利用可能なコンポーネント」と謳ってますが、Favoriting Componentも何か特別な構造を持つわけではなく、この4つで構成されています。

ただ、ジェネリックな型を扱う関係もあり、Reducerだけはグローバルなインタンスとして宣言するのではなく、関数で生成するようになっています。

そして、この関数の構造が若干複雑に見えて、最初は混乱してました。

extension Reducer {
  /// Enhances a reducer with favoriting logic.
  func favorite<ID>(
    state: WritableKeyPath<State, FavoriteState<ID>>,
    action: CasePath<Action, FavoriteAction>,
    environment: @escaping (Environment) -> FavoriteEnvironment<ID>
  ) -> Reducer where ID: Hashable {
    .combine(
      self,
      Reducer<FavoriteState<ID>, FavoriteAction, FavoriteEnvironment> {
        state, action, environment in
        switch action {
          ...  // 省略
        }
      }
      .pullback(state: state, action: action, environment: environment)
    )
  }
}


episodeReducerを見てみると、Reducer.favorite()メソッドはあるReducerにFavoriting機能を追加する、という意図が伝わってきます。

let episodeReducer = Reducer<EpisodeState, EpisodeAction, EpisodeEnvironment>.empty.favorite(
  state: \.favorite,
  action: /EpisodeAction.favorite,
  environment: { FavoriteEnvironment(request: $0.favorite, mainQueue: $0.mainQueue) }
)


再利用可能なコンポーネントはこうやって実装するのかあと最初はなんとなく思ってたんですが、実は上記の実装はこう書き換えることができます。

extension Reducer {
    static func favoriteReducer<ID: Hashable>() -> Reducer<FavoriteState<ID>, FavoriteAction, FavoriteEnvironment<ID>> {
        .init { state, action, environment in
            switch action {
            ...  // 省略
            }
        }
    }
}

let episodeReducer = Reducer<EpisodeState, EpisodeAction, EpisodeEnvironment>.combine(
    .empty,
    .favoriteReducer().pullback(
      state: \.favorite,
      action: /EpisodeAction.favorite,
      environment: { FavoriteEnvironment(request: $0.favorite, mainQueue: $0.mainQueue) }
    )
)


こうしてみると、Getting Startedのサンプルの実装と同じ感じになりますね。

個人的にはReducer.combineでReducerを組み合わせるという構造の方が理解しやすいなあと思いますが、前述の書き方の方がメリットがあったりするのでしょうか?

ただの書き方の違いなのか、前述の書き方の方が色々良い点があるのかどうか、そこまではまだわかっていません。


Stateの構造

EpisodeStateの構造に着目してみます。

EpisodeStateに対応するビューであるEpisodeViewはこの各行を表現しています。

f:id:bamboohero:20210906012518p:plain


EpisodeStateの実装はこうです。

struct EpisodeState: Equatable, Identifiable {
  var alert: AlertState<FavoriteAction>?
  let id: UUID
  var isFavorite: Bool
  let title: String

  var favorite: FavoriteState<ID> {
    get { .init(alert: self.alert, id: self.id, isFavorite: self.isFavorite) }
    set { (self.alert, self.isFavorite) = (newValue.alert, newValue.isFavorite) }
  }
}


FavoriteState型のプロパティをStoredプロパティではなくComputedプロパティで実装しているのですが、これの意図がいまいち読み取れていません。

例えば、EpisodeViewでalertやisFavoriteは参照してないのでEpisodeStateで持つ必要あるのかな?と疑問に思います。

仮に、FavoriteState.isFavoriteの状態に応じてビューを変えたいとなった場合でも、普通にfavorite.isFavoriteを参照すればよいだけです。

struct EpisodeView: View {
  let store: Store<EpisodeState, EpisodeAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      HStack(alignment: .firstTextBaseline) {
        Text(viewStore.title)
          // ここを追加してみた
          .foregroundColor(viewStore.favorite.isFavorite ? .red : .black)

        Spacer()

        FavoriteButton(
          store: self.store.scope(state: \.favorite, action: EpisodeAction.favorite))
      }
    }
  }
}


Computedプロパティを使わないとすると、こんな実装になるかと思います。
EpisodeViewでalertとisFavoriteは参照してないので、プロパティを削除してます。

struct EpisodeState: Equatable, Identifiable {
  let id: UUID
  let title: String
  var favorite: FavoriteState<UUID>

  init(id: UUID, isFavorite: Bool, title: String) {
    self.id = id
    self.title = title
    self.favorite = .init(id: id, isFavorite: isFavorite)
  }


イニシャライザ書かないといけないというのはありますが、ビューで参照してないプロパティを持つのも微妙かなと思うので、これで良いんじゃないかなと。

サンプルが単純すぎるだけで、もっと複雑なStateの構造になったらComputedプロパティで実装するメリットが出るんでしょうかね。


おわりに

当初は若干複雑に見えたサンプルでしたが、分解して見るとそれまでのサンプルとそれほど変わらない構造であることがわかりました。

もしかしたら、私が読み解けていない部分が多々あるような気もしますが、、今後自分の関わっているプロジェクトで再利用可能なコンポーネントを実装しようとなったときに、このサンプルの実装の意図がわかるのかもしれません。