宣言的UIの副作用に対する注目すべきアイディア

この記事は [株式会社ビットキー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bitkey) 4日目の記事です。

---

個人的に、今年1年は色々な宣言的UIフレームワークを触った年でした。 React/SwiftUI/Jetpack Compose/Flutter をそれぞれ触った中で、似ている概念やアイディアをまとめてみたいと思います。移植を行うときの参考にしていただければ幸いです。

## 非同期処理

PCやスマホ上で計算や条件分岐・ループといったCPUとメモリが動くペースに対して、ファイルを保存したり通信を行ったりするペースは遥かに遅く、様々な周辺機器に司令を出して動いているうちに他の処理を進めることができれば、より効率的に目的を達成することができます。

代表的な例として、 [Node.js の fs モジュール](https://nodejs.org/dist/latest/docs/api/fs.html) があります。結果をコールバックで受け取る `fs.open` (Callback API) と、同期的に返される `fs.openSync` (Synchronous API) が存在します。

周辺機器の処理の完了を戻り値の代わりにコールバックで受け取るようにすることで、全体を効率的に動かすようにできるようにはなったのですが、コールバックのネストが大量に発生するようになります。可読性の悪さから、2010 年代前半あたりでは「コールバック地獄」と呼ばれていました。[^1]

[^1]: https://techblog.yahoo.co.jp/programming/js_callback/

その後、このコールバック地獄問題は `Promise` や `async`/`await` によって解決されます。また、JavaScript以外の多くの言語も、これらの課題に対処するアイディアが存在します。

* **JavaScript**
  * [プロミスの使用 - JavaScript - MDN Web Docs](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises)
  * [JavaScript Promiseの本](https://azu.github.io/promises-book/)
* **Kotlin**
  * [Android での Kotlin コルーチン](https://developer.android.com/kotlin/coroutines?hl=ja) 
  * [Coroutines | Kotlin Documentation](https://kotlinlang.org/docs/coroutines-overview.html)
* **Swift**
  * [Concurrency | Apple Developer Documentation](https://developer.apple.com/documentation/swift/concurrency)
* **Dart**
  * [Asynchronous programming: futures, async, await | Dart](https://dart.dev/codelabs/async-await)

## 非同期イテレータ

連続的に発生するイベントに対してListenerを登録して購読するパターンは、購読の中止まで含めて一つの概念として構築されるようになりました。また、文法に組み込まれて `for` 文で記述したりすることもあります。

* **JavaScript**
  * [反復処理プロトコル - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Iteration_protocols)
  * [for await...of - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for-await...of)
* **Android, Kotlin**
  * [LiveData の概要 | Android デベロッパー](https://developer.android.com/topic/libraries/architecture/livedata?hl=ja)
  * [Asynchronous Flow](https://kotlinlang.org/docs/flow.html)
  * [Android での Kotlin Flow](https://developer.android.com/kotlin/flow?hl=ja)
* **Swift**
  * [AsyncIteratorProtocol | Apple Developer Documentation](https://developer.apple.com/documentation/swift/asynciteratorprotocol)
* **Dart**
  * [Asynchronous programming: Streams](https://dart.dev/tutorials/language/streams)

加えて、 [RxJS](https://rxjs.dev/) をはじめとする [ReactiveX](https://reactivex.io/) や [Swift Combine](https://developer.apple.com/documentation/combine) も関連するアイディアとして紹介しておくべきでしょう。 [Subject](https://reactivex.io/documentation/subject.html) や Kotlin Flowのhotとcoldの考え方は、連続的に発生するイベントを扱う上でのヒントになるはずです。

## Cooperative Cancellation

直訳すると「協調的なキャンセル」で、これは非同期処理を途中で止まれるようにするときに出てくるアイディアです。

一連の非同期処理を含む処理の起動で、 `Job` (Kotlin) や `Task` (Swift) オブジェクトを作成します。このオブジェクトに対してキャンセルを要求すると、例外をスローしたりゼロ値や途中結果を返して終了するようになります。逆にキャンセルをサポートする側は、そのように実装しなければなりません。

* **Kotlin**
  * [Cancellation is cooperative﻿ (Cancellation and timeouts | Kotlin Documentation)](https://kotlinlang.org/docs/cancellation-and-timeouts.html#cancellation-is-cooperative)
  * [コルーチンをキャンセル可能にする (Android でのコルーチンに関するベスト プラクティス | Kotlin)](https://developer.android.com/kotlin/coroutines/coroutines-best-practices?hl=ja#coroutine-cancellable)
* **Swift**
  * [Task Cancellation (Concurrency - Documentation - Swift.org)](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Task-Cancellation)
  * [The importance of cooperative cancellation | Alexito's World](https://alejandromp.com/blog/the-importance-of-cooperative-cancellation/)

JavaScriptやDartでは、非同期処理のキャンセルが文法や言語ではなくFetch APIなどの個々のAPIで定められています。

* **JavaScript**
  * [clearTimeout() - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/clearTimeout)
  * [AbortController - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/AbortController)
* **Dart**
  * [cancel method - Timer class - dart:async library](https://api.flutter.dev/flutter/dart-async/Timer/cancel.html)
  * [CancelToken class - dio library - Dart API](https://pub.dev/documentation/dio/latest/dio/CancelToken-class.html)

## コンポーネントと非同期処理をつなぐ

非同期処理をUIと組み合わせる実装で気をつけるべきポイントの一つに、登録したまま終わらなかったり中止せずに溜まってしまうリークの懸念があります。購読と中止はなるべく自分で直接扱わずに、フレームワークやライブラリに任せてしまいたいところです。

### スコープを作成する

コンポーネントを表示している期間で非同期処理のスコープ (CoroutineScope, Task) を構成し、非表示になったらキャンセルするという機能をUIライブラリとして標準で持っているパターンがあります。

* **Kotlin, Jetpack Compose**
  * `LaunchedEffect`
  * `rememberCoroutineScope`
  * [Compose における副作用 - Jetpack](https://developer.android.com/jetpack/compose/side-effects?hl=ja)
* SwiftUI
  * [task(priority:_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/task(priority:_:))

Reactの場合は、例えば `AbortController` と `useEffect` の組み合わせで実現できます。

```js
useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;
  fetch(url, { signal })
    .then((response) => {
      console.log("Complete", response);
    })
    .catch((err) => {
      console.error(`Error: ${err.message}`);
    });
  return () => controller.abort();
}, []);
```

同様の組み合わせはDartなら `State.dispose` とDioの `CancelToken` などで実現することになるでしょう。

### スナップショット化する

主にデータ取得処理で見られるパターンとして、表示する瞬間の状態をスナップショットとして取得できるようにする方法もあります。

* **JavaScript, React**
  * [SWR](https://swr.vercel.app/)
  * [TanStack Query](https://tanstack.com/query)
* **Dart, Flutter**
  * [FutureBuilder class - widgets library - Dart API](https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html)
  * [FutureProvider](https://riverpod.dev/ja/docs/providers/future_provider)

連続的なイベントの発生を表す非同期イテレータも、スナップショット化の方法が用意されていることがあります。

* **Kotlin**
  * [collectAsState](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsState(kotlin.coroutines.CoroutineContext))
* **Dart, Flutter**
  * [StreamBuilder](https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html)
  * [StreamProvider](https://riverpod.dev/ja/docs/providers/stream_provider)

## View Modelの見直し

宣言的UIとView Modelという組み合わせは、個人的には警戒すべきという意味でややネガティブに考えています。PageやScreenといった大きなViewの状態をView Modelで管理してしまうと、コンポーネントの再利用性を著しく損なわれてしまいます。Viewが再利用性の低いView Modelに依存すると、そのコンポーネントは別のPageやScreenで再利用することが難しくなります。とはいえ、AndroidのXMLレイアウトやiOSのUIKit時代の資産や考え方の流用を考えると、今すぐ全部撤廃するのもそれはそれで難しいという感覚でいます。

* [「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由](https://qiita.com/karamage/items/8a9c76caff187d3eb838)
* [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file)
* [状態と Jetpack Compose](https://developer.android.com/jetpack/compose/state?hl=ja)

## Dependency Injection

状態管理を変化させる部分にRepositoryなどの依存するオブジェクトをどのように差し込むのか？という問題は、宣言的UIになっても依然として存在します。

* **Kotlin**
  * [Dagger](https://dagger.dev/dev-guide/), [Hilt](https://dagger.dev/hilt/)
  * [Koin](https://insert-koin.io/)
* **Swift**
  * [Swinject]()
  * [Factory](https://github.com/hmlongco/Factory)
  * [swift-dependencies](https://github.com/pointfreeco/swift-dependencies)
* **Dart, Flutter**
  * [Riverpod](https://riverpod.dev/ja/)

Riverpodは、宣言的UIのいくつかの要素をまとめてカバーできる点でユニークな解決策を持ったライブラリだと感じています。元々が改善されたProviderというコンセプトなので、Provider/Contextの代替として利用できます。また、 `ref.read`/`ref.watch` で他の `Provider` を注入できることから、Dependency Injectionとしての側面も持ち合わせています。 `FutureProvider` や `StreamProvider` を各コンポーネントで `watch` して Snapshot を得るという動作は、 `useSWR` や `useReactQuery` と似ている部分だと思います。

## Context/Provider

どのフレームワークにも引数のバケツリレーを避けて子孫全体に値を受け渡す方法があります。見た目を整えるThemeや言語を切り替えるLocalizationオブジェクトを配る際に登場します。

* **JavaScript, React**
  * [コンテクストで深くデータを受け渡す](https://ja.react.dev/learn/passing-data-deeply-with-context)
* **Kotlin, Jetpack Compose**
  * [CompositionLocal でローカルにスコープ設定されたデータ](https://developer.android.com/jetpack/compose/compositionlocal?hl=ja)
* **Swift, SwiftUI**
  * [Environment](https://developer.apple.com/documentation/swiftui/environment)
  * [EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject)
* **Dart, Flutter**
  * [InheritedWidget class - widgets library - Dart API](https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html)

### Actions/Intent

Flutterの [Actions API](https://api.flutter.dev/flutter/widgets/Actions-class.html) は、イベントハンドラによる子孫から祖先方向のバケツリレーをContextのような受け渡し方法で解決してしまうものです。祖先から子孫に情報を配るのとは逆に、子孫側で発生させたIntentを祖先側でハンドルすることができます。

https://youtu.be/XawP1i314WM

乱用は危険そうですが、使い方によっては途中のグルーコードを省略できるため、うまく機能する状況もあるのではないかと考えています。

## 最後に

宣言的UIフレームワークで開発する際に出てくる要素をいくつかまとめてみました。「Reactの〇〇ってSwiftUIだと何て言うの？」みたいなときに参考にしていただければと思います。

5日目の [株式会社ビットキー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bitkey) は @uminoooon18 が担当します！