useSyncExternalStoreを使って、Subscribe(購読)形式のデータ取得をhook化する
useSyncExternalStoreとは
- React18から追加された
- 外部にあるデータソースをReact Hookに変換するためのhooks
- 外部ネットワークやアプリケーション外から subscribe で取得する形式のデータをhooksでstoreとして扱うことができる
型
export function useSyncExternalStore<Snapshot>( subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => Snapshot, getServerSnapshot?: () => Snapshot, ): Snapshot;
パラメータ | 説明 |
---|---|
subscribe | subscribeを開始する関数を渡す。戻り値には、「subscribeを解除する関数」を返す。ストアの状態通知関数としてonStoreChangeを受け取る。 |
getSnapshot | ストアの値を返す関数。 |
getServerSnapshot | Server Component用の引数。Serverまたはクライアントでのハイドレーション時にデータを返す関数。 |
例: 公式サンプル
import { useSyncExternalStore } from 'react'; import { todosStore } from './todoStore.js'; function TodosApp() { const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot); // ... }
let nextId = 0; // 実際のTODOリストデータ(ストアデータ) let todos = [{ id: nextId++, text: 'Todo #1' }]; // リスナーの管理 let listeners = []; export const todosStore = { addTodo() { todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }] emitChange(); }, // 第一引数には、listener関数(onStoreChange)が渡されてくる subscribe(listener) { listeners = [...listeners, listener]; return () => { listeners = listeners.filter(l => l !== listener); }; }, getSnapshot() { return todos; } }; function emitChange() { for (let listener of listeners) { // onStoreChangeを実行することで変更を通知できる listener(); } }
- データの変更を通知する場合は、subscribe時に渡されるlistener関数を管理して値の変更を通知する必要がある
どういう場面で役に立つのか
外部ネットワークからサブスクライブ形式でデータ取得をする場合、これまでReactでは、useEffect
と useState
を組み合わせてこのような非同期処理を行うことが多かった。(useEffect内で購読データの更新を受け取りStateを更新する)
しかし、 useSyncExternalStore
を使うことで、これらの非同期処理をReactのフックとして扱うことが可能になる。useEffectを使わないことで、データの取得とコンポーネントの状態更新をより直接的に結びつけることができるようになった。
React 18からは「Suspense」が登場し、レンダリングの途中でコンポーネントの処理を中断できるようになったため、useEffect
を使わないデータ取得のアプローチが多くなってきている。この流れにのって脱useEffectデータフェッチを目指すプロジェクトでは、useSyncExternalStore
を使ったデータ取得が有効なアプローチになる。
活用例1: Firestoreによるデータ取得
Subscribe形式のデータ取得の例として、リアルタイムデータベースのFirestore(Firebase)がある。
FirestoreでチャットアプリなどデータをSubscribeする場合、useEffectを使うと次のような形になる。
useEffectの例
const ChatComponent: React.FC<{ userUid: string }> = ({ userUid }) => { const [messages, setMessages] = useState<ChatMessage[]>([]); useEffect(() => { const reference = query( collection(getFirestore(), COLLECTION_NAME, userUid, SUB_COLLECTION_NAME), ).withConverter(ChatMessageFirestoreDataConverter); // データを購読してコールバック内で新しいデータを取得する const unsubscribe = onSnapshot( reference, (doc) => { const addedMessages: ChatMessage[] = []; doc.docChanges().forEach((change) => { // 実際は削除/更新などの処理も必要 if (change.type === 'added') { addedMessages.push(...change.doc.data()); } }); setMessages([...messages, ...addedMessages]); }, ); // コンポーネントのアンマウント時に購読を解除 return () => { unsubscribe(); }; }, [userUid]); return ( <div> {messages.map((message, index) => ( <div key={index}> {/* メッセージの内容を表示 */} </div> ))} </div> ); };
onSnapshotで、渡すコールバック内でFirestoreのデータが更新されるたびにuseStateの値を更新することで、再レンダリングを行なっている。
購読の解除は、useEffectのクリーンアップ関数で行っている。
useSyncExternalStoreの例
これを、useSyncExternalStoreを使って書き換えると次のようになる。
llet messages: ChatMessage[] = []; const firebaseStore = { // subscribeに渡す関数 subscribe(userUid: string): (onStoreChange: () => void) => () => void { return (onStoreChange) => { const reference = query( collection(getFirestore(), COLLECTION_NAME, userUid, SUB_COLLECTION_NAME), ).withConverter(ChatMessageFirestoreDataConverter); const unsubscribe = onSnapshot( reference, (doc) => { const addedMessages: ChatMessage[] = []; doc.docChanges().forEach((change) => { // 実際は削除/更新などの処理も必要 if (change.type === 'added') { addedMessages.push(...change.doc.data()); } }); // ストアが変更されたことを通知 onStoreChange(); messages = [...messages, ...addedMessages]; }, ); return () => { // 購読解除時はメッセージも初期化する messages = []; // firebaseの購読を解除 return unsubscribe(); }; }; }, // getSnapshotに渡す関数 getSnapshot() { return messages; }, }; const ChatComponent: React.FC<{ userUid: string }> = ({ userUid }) => { const messages = useSyncExternalStore( useCallback(firebaseStore.subscribe(getAuth().currentUser!.uid), [getAuth().currentUser!.uid]), firebaseStore.getSnapshot, ); return ( <div> {messages.map((message, index) => ( <div key={index}> {/* メッセージの内容を表示 */} </div> ))} </div> ); };
subscribeのコントロールは、useSyncExternalStoreが行うため、コンポーネントがアンマウントされると自動でunsubscribeされる。
firestoreが更新された時(onSnapshot内のコールバックよばれた時)は、onStoreChangeを呼び出すことで、再度レンダリングが走り、コンポーネントが更新される。
活用例2: ネットワーク監視(ブラウザAPIの制御)
ブラウザのAPIにて、windowに対してイベントリスナーを使用する場合(addEventListener/removeEventListener)でもuseSyncExternalStoreが利用できる。
以下は公式のサンプルとして紹介されていた、navigator.onLine
というAPIから現在のネットワーク状況を監視するコード。
Reactのライフサイクル外のwindowイベントに対して、onStoreChangeを設定することで、useStateと同じ感覚で変更を監視できるようになる。
import { useSyncExternalStore } from 'react'; export default function ChatIndicator() { const isOnline = useSyncExternalStore(subscribe, getSnapshot); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; } function getSnapshot() { return navigator.onLine; } function subscribe(onStoreChange) { window.addEventListener('online', onStoreChange); window.addEventListener('offline', onStoreChange); return () => { window.removeEventListener('online', onStoreChange); window.removeEventListener('offline', onStoreChange); }; }
まとめ
サブスクライブ形式やReactの領域外のイベント監視など、コールバック形式での値取得は、useEffectで対応しがちだったが、useSyncExternalStoreを使うこと効率的にhooks対応することができるようになった。
通常のAPIデータフェッチについては、SWR など、人気のライブラリを使うことで、キャッシュ管理含め安全にhook化できるため、これらと併用してuseEffectを進めていきたい。
トレイトオブジェクトとトレイト境界の入門
Rustのトレイトオブジェクトとトレイト境界について整理。
トレイト(trait)
- 任意の型となりうるSelfに対して定義されたメソッドの集合
- 多言語、例えばJavaで言えばInterfaceのようなもの
trait Speak { fn speak(&self); }
トレイトオブジェクト
doc.rust-jp.rs doc.rust-lang.org
- あるトレイトを実装した構造体(インスタンス)とtraitメソッドを検索するためのテーブルを指すとある。
impl Speak for Dog { fn speak(&self) { println!("Woof!"); } } fn main() { // このanimalがトレイトオブジェクト let animal: Box<dyn Speak> = Box::new(Dog); animal.speak(); }
- 上記例でいうと、Box
型のanimalがトレイトオブジェクトになる。 - トレイトオブジェクトは、&またはBox
のようなスマートポインタを使って dyn
キーワードでトレイトの型を指定する必要がある。
トレイトオブジェクトを使うとポリモーフィズムが実現できる
trait Speak { fn speak(&self); } struct Dog; struct Cat; impl Speak for Dog { fn speak(&self) { println!("Woof!"); } } impl Speak for Cat { fn speak(&self) { println!("Meow!"); } } fn main() { let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; // 実際に中に入っている型は異なるが、同じSpeakトレイトを実装しているため、speakメソッドを呼べる for animal in animals { animal.speak(); } }
トレイト境界
- ジェネリクス型のパラメタを使い特定のトレイトが実装されているものだけに制限(制約)を掛ける
trait Speak { fn speak(&self); } struct Dog; impl Speak for Dog { fn speak(&self) { println!("Woof!"); } } // Speakトレイトを実装している構造体のみこの関数にわたすことができる fn speak_animal<T: Speak>(animal: T) { animal.speak(); } fn main() { speak_animal(Dog); }
- 境界という言葉に馴染みが持てなかったが、シンプルにジェネリクスによる制約をつけるという理解で良さそう?
トレイトオブジェクトとトレイト境界の違い
トレイトオブジェクトもトレイト境界もトレイト実装の構造体を絞り込めるが、実行時の挙動差異がある。
- ジェネリクス型を使ったトレイト境界は、コンパイル時に具体的な型(構造体)に対してジェネリクス定義ではない固有の実装としてコード生成される(スタティックディスパッチ)
- トレイトオブジェクトを使った場合、コンパイラは実行時にどのメソッドを呼び出すか決め、コードを生成する。(ダイナミックディスパッチ)
- 実行時コストがかかる
トレイトオブジェクトの制限
- トレイト実装の戻り値の型がSelfではいけない
トレイトオブジェクトをダウンキャストする
Javaなどと同じ感覚でInterfaceとして、トレイトを扱おうとするとダウンキャストしたくなるが、トレイトオブジェクトをダウンキャストするのは、そこまで簡単ではない。
ダウンキャストするためには、std::any::Any
トレイトの実装とdowncast_refを使う。
dyn <Trait>
型のトレイトオブジェクトになった時点で型情報が失われているため、これを一旦Any型にし、downcast_refで指定した型情報の参照に動的に変換する必要がある。
(downcast_refは、Option<&T>型を返す)
use std::any::Any; // Anyトレイトを実装する必要がある trait Speak: Any { fn speak(&self); fn as_any(&self) -> &dyn Any; } struct Dog { name: String, } struct Cat { name: String, } impl Speak for Dog { fn speak(&self) { println!("Woof!"); } // an_anyで&dyn Anyで自身を返す fn as_any(&self) -> &dyn Any { self } } impl Speak for Cat { fn speak(&self) { println!("Meow!"); } fn as_any(&self) -> &dyn Any { self } } fn main() { let animals: Vec<Box<dyn Speak>> = vec![ Box::new(Dog { name: "dog".to_string(), }), Box::new(Cat { name: "cat".to_string(), }), ]; for animal in animals { // Some<&Dog>が返るのでアンラップできればダウンキャストで型付できている if let Some(dog) = animal.as_any().downcast_ref::<Dog>() { println!("{}", dog.name); } else if let Some(cat) = animal.as_any().downcast_ref::<Cat>() { println!("{}", cat.name); } } }
- &dyn Anyに変換するということは、Any型のトレイトオブジェクトにしてからダウンキャストするという理解。
感想
RustによるResult型のエラーハンドリングの入門
RustにおけるResultを使った、エラーハンドリングの基本的な部分についてまとめておく。
Result型
Rustには、エラーハンドリングのための特別な型、Result
が用意されている。
enum Result<T, E> { Ok(T), Err(E), }
Ok
バリアントは操作が成功した場合に使用され、Err
バリアントはエラーが発生した場合に使用される。
[例]
use std::fs; use std::io; fn read_file_content(file_path: &str) -> Result<String, io::Error> { fs::read_to_string(file_path) } fn main() { match read_file_content("path/to/your/file.txt") { Ok(content) => println!("File content: {}", content), Err(err) => eprintln!("Failed to read the file: {}", err), } }
上記はファイルの読み込みのサンプル。
特定のファイルを文字列として読み込む関数(fs::read_to_string)は、Result型を返す。
?
演算子
Rustには、多言語における例外のthrowなどはなく基本は関数(メソッド)のReturnをハンドリングすることになる。(主にResultやOption)
多くの関数では、ハンドリングするエラーが発生することがあり、毎回ResultのOk/Errをハンドリングすると処理が煩雑になってしまうことが考えられる。このような問題の対策として、Rustには ?
演算子が用意されている。
?
演算子は、Resultを簡単に扱うための構文糖衣で、関数がResult
型を返す場合、?
を使うことでエラーが発生したら早期にその関数からリターンすることができる。
例えば、?演算子を使わない場合は以下のようにmatchなどを使ってハンドリングする必要がある。
【?演算子を使用しない】
fn func1() -> Result<String, &'static str> { // 戻り値のResultをハンドリングする必要がある match func2() { Ok(val2) => match func3(val2) { Ok(val3) => func4(val3), Err(e) => Err(e), }, Err(e) => Err(e), } } fn func2() -> Result<String, &'static str> { Ok("func2".to_string()) } fn func3(val: String) -> Result<String, &'static str> { Ok(format!("{} -> func3", val)) } fn func4(val: String) -> Result<String, &'static str> { Ok(format!("{} -> func4", val)) } fn main() { match func1() { Ok(result) => println!("Result: {}", result), Err(e) => println!("Error: {}", e), } }
【?演算子を使用する】
fn func1() -> Result<String, &'static str> { // ?演算子を使う場合Errの場合は、その時点でResult(Err)が返される let val2 = func2()?; let val3 = func3(val2)?; func4(val3) } ・・・ fn main() { match func1() { Ok(result) => println!("Result: {}", result), Err(e) => println!("Error: {}", e), } }
?演算子は、同じErrの型のみ返すことができる
?演算子をある関数内で使用するときは、呼び出す関数のResult
【正しいパターン】
use std::fs; use std::io; fn read_file1() -> Result<String, io::Error> { fs::read_to_string("path/to/file.txt") } fn read_file2() -> Result<String, io::Error> { fs::read_to_string("path/to/file2.txt") } fn function() -> Result<(), io::Error> { // 両方ともio::Errorを返すため問題ない let _content = read_file1()?; let _content = read_file2()?; Ok(()) }
【コンパイラエラーのパターン】
fn read_file() -> Result<String, io::Error> { fs::read_to_string("path/to/file.txt") } fn another_task() -> Result<(), &'static str> { Err("This is a different error type") } fn function() -> Result<(), io::Error> { let _content = read_file()?; // Errの型がstrなのでコンパイルが通らない another_task()?; Ok(()) }
カスタムエラーのハンドリング
一つの関数の中で、ハンドリングするエラーの型が必ず同じものであることは稀のため、関数におけるエラーの型を独自で制御する必要が出てくる。
map_errを使ってエラー型を変換する
あるエラー型を別のエラー型に変換するために、map_errという関数が用意されている。
以下のサンプルのように、特定のError型(RustではEnumで作るのが慣習)に、 map_err
関数で変換することで?演算子を使ってスリムに処理を書くことができる。
use std::fs; use std::io; #[derive(Debug)] enum CustomError { Io(io::Error), Str(&'static str), } fn read_file() -> Result<String, io::Error> { fs::read_to_string("path/to/file.txt") } fn another_task() -> Result<(), &'static str> { Err("This is a different error type") } fn function() -> Result<(), CustomError> { let _content = read_file().map_err(CustomError::Io)?; another_task().map_err(CustomError::Str)?; Ok(()) } fn main() { match function() { Ok(_) => println!("Success"), Err(e) => println!("Error: {:?}", e), } }
Fromトレイトを実装する
?演算子でエラーをハンドリングした時に特定のエラーに変換するために、From
トレイトが用意されている。
このトレイトを実装することで、map_err関数を使わなくても、特定のカスタムエラー型へ変換することができる。
use std::fs; use std::io; #[derive(Debug)] enum CustomError { Io(io::Error), Str(&'static str), } impl From<io::Error> for CustomError { fn from(err: io::Error) -> CustomError { CustomError::Io(err) } } impl From<&'static str> for CustomError { fn from(err: &'static str) -> CustomError { CustomError::Str(err) } } fn read_file() -> Result<String, io::Error> { fs::read_to_string("path/to/file.txt") } fn another_task() -> Result<(), &'static str> { Err("This is a different error type") } fn function() -> Result<(), CustomError> { let _content = read_file()?; another_task()?; Ok(()) } fn main() { match function() { Ok(_) => println!("All good"), Err(e) => println!("Error: {:?}", e), } }
anyhowを使ってエラーを扱う
ここまで、Rustの基本的なエラーハンドリングについて書いてきたが、多くのライブラリ関数のエラー型を全て変換することは、コストでもあるため問題解決のライブラリが用意されている。
anyhowクレートでは、異なるエラータイプを一つの型に変換するための機能が提供されており、anyhow::Result
を使うことでエラー型をanyhow::Error
にまとめることができる。
use anyhow::{anyhow, Context, Result}; fn read_file() -> Result<String> { // contextは、map_errのように特定のエラーをanyhow::Errorに変換する fs::read_to_string("path/to/file.txt").context("Unable to read file") } fn another_task() -> Result<()> { Err(anyhow!("This is a different error type")) } // Result<T, E = Error> なのでErrは指定しなければ anyhow::Error型になる fn function() -> Result<()> { let _content = read_file()?; another_task()?; Ok(()) } fn main() { match function() { Ok(_) => println!("Success"), Err(e) => println!("Error: {}", e), } }
thiserrorを使ってカスタムエラーを一つにまとめる
anyhow::Errorにまとめることで、とりあえず何も考えずエラーを一つに簡単にまとめられる。
簡単なプログラムであればこれで十分でストレス値は大分減らすことができるが、複雑なアプリケーションを書くときは、カスタムエラーを関数ごとに適切にハンドリングしたくなるケースがある。
#[derive(Debug)] enum CustomError { Io(io::Error), Str(&'static str), }
カスタムエラーを?演算子でResultにわたすためには、Fromトレイトが必要だが、これを簡単に記載できるthiserrorというクレートがある。
thiserrorはstderrorErrorの実装をマクロ化して便利に提供してくれているクレートで、この中にはFromトレイトのマクロもある。
#[derive(Debug, thiserror::Error)] enum Error { #[error("Failed io error] {}", .0)] IoError(#[from] io::Error), #[error("{}", .0)] Unknown(#[from] anyhow::Error), }
thiserror::Error
を使うことで、Fromトレイトの実装をマクロで記載できるため実装がスリムになる。
use anyhow::{anyhow, Result}; use std::fs; use std::io; #[derive(Debug, thiserror::Error)] enum CustomError { #[error("Failed io error] {}", .0)] IoError(#[from] io::Error), #[error("{}", .0)] Unknown(#[from] anyhow::Error), } fn function() -> Result<String, CustomError> { let file = fs::read_to_string("credentials.json")?; another_task()?; Ok(file) } fn another_task() -> Result<(), anyhow::Error> { Err(anyhow!("This is a different error type")) } fn main() { match function() { Ok(_) => println!("Function completed successfully"), Err(e) => match e { CustomError::IoError(io_err) => println!("IO error occurred: {}", io_err), CustomError::Unknown(anyhow_err) => { println!("An unknown error occurred: {}", anyhow_err) } }, } }
このように anyhow, thiserrorを使うと自前のトレイト実装が極力減らせ、?演算子を使ってストレスなくコーディングができる。
ここでは記載していないが、stderrorError のsourceや、std::fmt::Display のマクロを使って簡単にカスタムエラーを定義することもできる。
Riverpod(v2)を使ったViewModelの作り方
概要
Flutterで riverpod という状態管理ライブラリを使って、MVVMアーキテクチャにおけるViewModelを作成する。 riverpodは、v2になり色々と作成方法も変わったので、基本v2でアノテーションによるコード生成を使う前提で記載。
使用するライブラリ
freezedはViewModelがViewに対して公開する状態を管理するために使用。 (Stateはimmutableであることが好ましいため。)
Flutterで新規プロジェクト作成時のカウンターアプリ
簡単なサンプルとして、Flutterのプロジェクト作成時に生成されているカウンターアプリの機能をViewModelを使って実装する。
[初期状態のコード]
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), // This trailing comma makes auto-formatting nicer for build methods. ); } }
ディレクトリ構成
lib/ ├── main.dart └── widget/ ├── app.dart └── pages/ └── home/ ├── home_page.dart ├── view_model.dart └── ui_state.dart
pages/ ディレクトリを作成し、各ページごとに一つのディレクトを作成する。
※ view_model.dart, ui_state.dartについては、コード生成により、生成される <file名>.g.dart
も一緒に作成される。
状態ファイルの作成
import 'package:freezed_annotation/freezed_annotation.dart'; part 'ui_state.freezed.dart'; @freezed class UiState with _$UiState { const factory UiState({ required int counter, }) = _UiState; }
Viewに公開したい情報は、インクリメントされるカウンターの数値のためこれをプロパティとして持つUiStateというクラスを作成する。
freezedによりコード生成するため、@freezed
アノテーションと ui_state.freezed.dart
というファイルを part で読み込む必要がある。
dart run build_runner build
というコマンド実行で、freezedの生成ファイル(ui_state.freezed.dart
)が生成される。
ViewModelファイルの作成
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'ui_state.dart'; part 'view_model.g.dart'; @riverpod final class ViewModel extends _$ViewModel { @override UiState build() { return const UiState( counter: 0, ); } }
RiverpodでViewModelを作るときは、NotifierProviderを使う。
NotifierProviderを自動生成するときは、build
というメソッドをoverrideして、ここで先程のUIStateを初期化する。
freezedと同様にこちらも、コード生成するため、 @riverpod
、part 'view_model.g.dart';
を記載して、
dart run build_runner build
でコード生成する。
ViewModelファイルに incrementCounter
メソッドを追加する
@riverpod final class ViewModel extends _$ViewModel { @override UiState build() { return const UiState( counter: 0, ); } void incrementCounter() { state = state.copyWith(counter: state.counter + 1); } }
ViewからViewModelに対して、カウンター増加を通知するためのメソッドを追加する。
stateプロパティから、現在管理しているUiStateインスタンスにアクセスでき更新が可能。
freezedは、一部のプロパティを更新した新しいインスタンスを copyWith
というメソッドをもっていて、これを使うことで、counterを更新した新しいUiStateのインスタンスを生成して、stateを更新することができる。
state = state.copyWith(counter: state.counter + 1);
ViewModelを利用するPageウィジェットの完成コード
class HomePage extends ConsumerWidget { const HomePage({super.key, required this.title}); final String title; @override Widget build(BuildContext context, WidgetRef ref) { final viewModel = ref.read(viewModelProvider.notifier); final counter = ref.watch(viewModelProvider); return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'You have pushed the button this many times:', ), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: viewModel.incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } }
Riverpodを利用するウィジェットは ConsumerWidget
を継承する必要がある。
NotifierProviderからViewModelを参照する場合、 viewModelProvider.notifier
のようにnotifierをreadすればよい。(viewModelは変更を受け取る必要がないため、ref.readを使う)
NotifierProviderからUiStateを参照する場合、viewModelProviderをref.watchを使って参照する。
(UiStateは変更を検知する必要があるため、watchを使う)
実際のコード
useTransitionについて理解する
useTransitionとは
https://react.dev/reference/react/useTransition
useTransitionは、React 18で新しく導入されたhooks。
useTransitionは、startTransitionという関数を提供していて、このstartTransitionに渡した関数内で状態を更新された場合、その状態更新でのレンダリングはノンブロッキングになる。
主な目的としては、レンダリングに非常に重い(遅い)処理がある画面レンダリングを遅延して、レンダリングが完了してから表示するために利用する。
レンダリングに遅い処理がある場合、何も対応しないとその間画面操作がブロッキングされることがある。
■ useTransitionを使わないときの処理の流れ
■ useTransactionを使ったときの流れ
- startTransitionを使ってsetStateで値を更新
- 画面表示は変わらず(ペンディング状態)、裏側でレンダリングが実行される。(この間ユーザーは、画面操作が可能)
- 画面レンダリングが完了した時点で画面が切り替わる
この裏側でレンダリング中に別の状態更新が起こった場合は、裏側のレンダリングをReactがキャンセルしてくれる。
使い方
function TabContainer() { const [isPending, startTransition] = useTransition(); const [tab, setTab] = useState('about'); function selectTab(nextTab) { startTransition(() => { setTab(nextTab); }); } }
[isPending, startTransition] = useTransition();
useTransitionは、2 つの値を配列として返す。
startTransitionに渡された関数内で状態の更新が発生すると、その更新はトランジションとしてマークされ、画面表示はレンダリング完了まで遅延するようになる。
https://react.dev/reference/react/useTransition#examples
実際の挙動の違いは、公式のサンプルを比較するのがわかりやすい。
startTransitionを使うことででUIブロッキングを防ぐことができる。
Suspense コンポーネントとの組み合わせ
<Suspense fallback={'loading...'}> <Router /> </Suspense>
startTransitionを使うと、内部でPromiseがthrowされている状態(ペンディング状態)でもSuspenseのfallbackコンポーネントは表示されない。
startTransitionの遅延レンダリングが優先され、画面には更新前の画面が表示される。
(isPendingによる読み込み表示は可能)
基本的にSuspense内で画面切り替えを行うときは、startTransitionを使うことを推奨されている。
理由は、以下2点とのこと。
- 画面遷移中にユーザーが別操作で遷移を中断することが可能
- 遷移のたびに、全体のローディングを表示するとUXを下げるため
useDeferredValue との違い
https://react.dev/reference/react/useDeferredValue
同じような役割として、useDeferredValueがある。
こちらは、変更対象の値をマークしていて、その値を使ってレンダリングが発生したときにレンダリング完了まで画面表示を遅延させてくれる。
目的は、似ているが、useTransitionは画面遷移などの状態更新で使う。useDeferredValueはテキストBoxなどの入力値に対する再レンダリングで使用する。
(同じことをどちらでもできるシーンはありそうだが、このあたりは実際に使って理解していきたい)
その他注意事項
startTransition内で実行する状態更新は同期でなくてはならない
startTransition(() => { setTimeout(() => { setPage('/abou'); }, 1000); });
上記のようにstartTransition内の状態変更は同期処理でないとトランジションとしてマークされない。
startTransitionにわたす関数は同期実行される
console.log(1); startTransition(() => { console.log(2); setPage('/about'); }); console.log(3);
このログは、1, 2, 3の順で表示される。
つまり、startTransition内の関数もstartTransition内で同期実行される。
useDeferredValueについて理解する
useDeferredValueについて
https://react.dev/reference/react/useDeferredValue#usedeferredvalue
useDeferredValueは、React 18で新しく導入されたhooks。
Suspenseコンポーネントによって、コンポーネントのレンダリングに遅延が発生する場合、最新のデータを取得するまでの間、古いデータを表示しておきたいケースなどに使用される。
useDeferredValue が更新された時、まずは裏側で更新後の値でレンダリングを試行する。
遅延なくレンダリングされた場合は、最新の値を反映する。もし、最新の値でのレンダリングが遅延している場合は、そのまま古い値でのレンダリングを継続する。
(値の更新時にそれを使ったコンポーネントがある場合は、完全にレンダリングできるまでは待機してくれるイメージ)
https://react.dev/reference/react/useDeferredValue#how-does-deferring-a-value-work-under-the-hood
こちらを読むに、更新時は古い値でもレンダリングを試行しているとのこと。
ユースケース
- 最新の情報を取得中に古い情報を見せておく
- 今の情報が古いことをユーザーに伝える
- 頻度が高すぎるレンダリングを遅延させパフォーマンスを改善させる
基本的な使い方
React公式のサンプルコードをお借りする。
import { Suspense, useState } from 'react'; import SearchResults from './SearchResults.js'; export default function App() { const [query, setQuery] = useState(''); return ( <> <label> Search albums: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <Suspense fallback={<h2>Loading...</h2>}> <SearchResults query={query} /> </Suspense> </> ); }
SearchResults
コンポーネントは、遅延レンダリングを含むコンポーネントでqueryが更新されるごとにデータの更新を非同期で行う。
この更新中は、Promiseを待機しているため、Suspenseコンポーネントの <h2>Loading...</h2>
が呼ばれる。
次に、useDeferredValueを使った場合。
export default function App() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); return ( <> <label> Search albums: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <Suspense fallback={<h2>Loading...</h2>}> <SearchResults query={deferredQuery} /> </Suspense> </> ); }
queryが更新されたときにdeferredQueryを使ってSearchResultsを更新する。
この時、deferredQueryは、更新後の値を遅延しているため、Suspenseコンポーネントの <h2>Loading...</h2>
が表示されることはなくなる。
ただし、画面初期化時(空白文字 '' で初回のレンダリング時)については、遅延レンダリングが発生するためLoadingが表示される。
(サンプルコードでは、空白文字時は処理を中断しているのでLoadingは表示されない。)
レンダリングパフォーマンスの改善について
ユースケースのもう一つとしてレンダリングパフォーマンスの改善がある。
function App() { const [text, setText] = useState(''); return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <SlowList text={text} /> </> ); }
このようにユーザーのキーストロークごとにリストを更新するケースにおいて、Listのレンダリングコストが比較的高い場合に、UI操作をブロッキングしてしまう問題がある。
function App() { const [text, setText] = useState(''); const deferredText = useDeferredValue(text); return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <SlowList text={deferredText} /> </> ); }
このような場合に、useDeferredValueを使うと、新しい値を使ってレンダリングが完了されるまでは画面には反映されず、ユーザーがその間新しい操作をした場合は、レンダリングがキャンセルされるためユーザー操作のブロッキングが発生しなくなる。
こちらの更新サンプルをみて実際の挙動を確認するのがわかりやすい。
https://react.dev/reference/react/useDeferredValue#examples
キー入力ごとの処理や、連続した操作によるテクニックとして、DebounceやThrottle がよく使われるが、useDeferredValueで代用できる場合のメリットとしては以下がある。
- 固定の時間を定義する必要がない
- レンダリングのキャンセルをReactが制御してくれるのでより、オーバーヘッドが少ない
ErrorBoundaryについて理解する
Suspenseについての簡単なおさらい
Suspenseを使って非同期処理を含むReactコンポーネントを内包すると、Suspenseは未完了のPromiseをcatchして、完了まではfallbackのコンポーネントを表示してくれる。
ErrorBoundary
Suspenseがchildrenとして内包するコンポーネントは、 Promiseを処理する課程でエラー(例外)を発生する可能性がある。
その時一般的には、Errorオブジェクトがthrowされるが、レンダリング最中にPromiseを処理するようになったReact18以降では、このエラーをキャッチする仕組みとしてErrorBoundaryというコンポーネントを使用する。
基本的な使い方
<ErrorBoundary fallback={"Something went wrong"}> <Suspense fallback={"loading..."}> <SuspendedComponent /> </Suspense> </ErrorBoundary>
基本的な使い方はSuspenseコンポーネント同様で、childrenで例外がthrowされた場合は、fallbackのコンポーネントが表示される。
ErrorBoundaryコンポーネント
ErrorBoundaryコンポーネントは、Suspenseとは異なり、Reactが提供しているコンポーネントではなく、自前で良いしなくてはならない。
どのように実装すれば良いかは、公式ドキュメント に記載されている。
公式が提示しているサンプルコードは以下のとおり。
class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) { // 子コンポーネントで例外が発生すると呼ばれる。 // 戻り値では、コンポーネントの状態を返す必要がある。 return { hasError: true }; } componentDidCatch(error, info) { // 発生したエラーのログを取得する logErrorToMyService(error, info.componentStack); } render() { if (this.state.hasError) { // エラーがある場合は、fallbackをレンダリングする return this.props.fallback; } // エラーがない場合は、childrenをレンダリングする return this.props.children; } }
重要なイベント関数は [static getDerivedStateFromError](https://react.dev/reference/react/Component#static-getderivedstatefromerror)
。
この関数を持つコンポーネントの子コンポーネントが例外をthrowしたときにこの関数が呼ばれる。
関数の引数として、throwされた例外を受け取り。処理後の状態を戻り値として返す。
[componentDidCatch(error, info)](https://react.dev/reference/react/Component#componentdidcatch)
という関数もあり、こちらもgetDerivedStateFromErrorと同様に例外発生時に呼び出されるが、現在はこの関数は主にログなど情報取得などに使うことを目的としており、状態の更新には推奨されない。
Function コンポーネントでErrorBoundaryを実装することはまだできない。
現時点で、FunctionコンポーネントとしてErrorBoundaryを実装することはできず、Classコンポーネントを使う必要がる。
クラスコンポーネントをアプリケーションで独自管理するのが嫌な場合は、ErrorBoundaryの実装を提供してくれている react-error-boundary のようなossを使用する。
ErrorBoundaryがキャッチしないエラー
Suspenseを使って、統一された設計をしていれば、起きない問題なのかもしれないが、ErrorBoundaryではキャッチできない例外が幾つかある。
古いドキュメントではあるが以下のようなものが該当する。
error boundary は以下のエラーをキャッチしません: イベントハンドラ(詳細) 非同期コード(例:setTimeout や requestAnimationFrame のコールバック) サーバサイドレンダリング (子コンポーネントではなく)error boundary 自身がスローしたエラー
よくあるパターンとして、useEffectからPromiseなどで通信処理を行う実装があるが、このようなときに発生したエラーも処理できない。
react-error-boundaryの実装参考
react-error-boundaryでは、SuspenseをつかないケースやuseEffect内やその他非同期ケースでのエラーを補足するために、hooksも提供してくれている。(useErrorBoundary)
export type UseErrorBoundaryApi<Error> = { resetBoundary: () => void; showBoundary: (error: Error) => void; }; export function useErrorBoundary<Error = any>(): UseErrorBoundaryApi<Error> { const context = useContext(ErrorBoundaryContext); assertErrorBoundaryContext(context); const [state, setState] = useState<{ error: Error | null; hasError: boolean; }>({ error: null, hasError: false, }); const memoized = useMemo( () => ({ resetBoundary: () => { context?.resetErrorBoundary(); setState({ error: null, hasError: false }); }, showBoundary: (error: Error) => setState({ error, hasError: true, }), }), [context?.resetErrorBoundary] ); if (state.hasError) { throw state.error; } return memoized; }
hooksが提供しているエラー発生の関数はshowBoundary
。
showBoundary: (error: Error) => setState({ error, hasError: true, })
if (state.hasError) { throw state.error; }
作りは非常にシンプルで、showBoundaryを非同期処理中の例外発生時で呼び出すとhooksのレンダリング中に例外としてthrowしてくれるというもの。
吐き出された例外はレンダリング中にthrowされるため、ErrorBoundaryで補足されるようになる。
感想
Suspenseコンポーネントを使う場合は、ErrorBoundaryはセットの実装となるが、ErrorBoundary自体は単体のデザインとして実装に組み込める。
既存プロダクトの導入として、Suspenseへの移行は段階的に置き換え計画が必要だが、ErrorBoundaryは比較的置き換えが用意のため、まずはこちらからという方針でも良さそう。
(実際に参画中のプロダクトではErrorBoundaryだけをまずは導入している。)