React Hooks に慣れている人が Flutter / Riverpod を学ぶとき、最初に理解すべきなのは「どの API がどの hook に対応するか」ではありません。
より重要なのは、UI が再構築される条件と、依存オブジェクトや command をどこから取得するかが、React と Flutter / Riverpod では違うという点です。
React では、component は state / props / context を入力として再実行されます。
また、event handler は component の lexical scope にある値を closure として参照できます。
一方、Flutter / Riverpod では、Provider の値を UI の再構築条件に含めたいなら ref.watch で明示的に購読します。
ボタン押下などの event handler から Provider に定義された repository、API client、Notifier、command を使いたいなら ref.read で取得します。
つまり、React から来た人にとっての最初のポイントはこれです。
React では state / props / context が component の暗黙の render 入力になる。
Riverpod では Provider をref.watchしたとき、その Provider が Widget / Provider の明示的な build 入力になる。
そして、もう一つ重要なのはこれです。
React では event handler が component scope の値を closure で捕まえられる。
Riverpod では event handler から Provider-managed な依存や command を使うとき、ref.readでその時点の値や Notifier を取り出す。
この記事では、React Hooks の感覚を足場にしながら、Flutter / Riverpod の watch、read、listen、Widget lifecycle を整理します。
React Hooks ユーザー向けに Flutter / Riverpod を比較するなら、次の表が出発点になります。
| やりたいこと | React | Flutter / Riverpod |
|---|---|---|
| UI を宣言する | Component が JSX を返す | Widget の build が Widget tree を返す |
| ローカル状態を持つ | useState, useReducer |
StatefulWidget + State + setState |
| state / props の変化で UI を更新する | React が component を再実行する | Flutter framework が対象 Widget を rebuild する |
| Provider の値で UI を更新する | Context / store hook / query hook などが内部で購読する | ref.watch(provider) で明示的に購読する |
| ボタン押下で repository / API client を呼ぶ | closure 内の client や mutation function を呼ぶ | ref.read(provider) で依存を取得して呼ぶ |
| ボタン押下で状態操作 command を呼ぶ | setter / dispatch / mutation を呼ぶ | ref.read(provider.notifier).method() を呼ぶ |
| 値の変化に反応して副作用する | useEffect(..., [dep]) |
ref.listen(provider, ...) |
| 初回生成・破棄に合わせて処理する | useEffect(..., []) と cleanup で書くことが多い |
initState / dispose、または Provider の lifecycle |
| 非同期データを UI に出す | useEffect + useState、TanStack Query、SWR など |
FutureProvider / AsyncNotifierProvider + AsyncValue |
この記事の中で一番重要なのは、次の 4 つです。
ref.watchは「この Provider を build の入力にする」という宣言ref.readは UI の購読ではなく、event handler や副作用の中から Provider-managed な依存や command を取り出すための APIref.listenは Provider の変化に反応して snackbar / navigation / logging などの副作用を起こすための API- React の event handler は closure で依存を捕まえるが、Riverpod では Provider にある依存を
ref.readで明示的に取得する
React の component は、state と props を読んで JSX を返します。
function Counter({ step }: { step: number }) {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + step)}>
{count}
</button>
);
}この component の render 結果は、count と step に依存しています。
ただし、React では次のように「count を購読します」「step を購読します」とは書きません。
useState の state や props は、component の入力として React に管理されています。
setCount が呼ばれれば、React は component を再実行します。
親から渡される step が変われば、React は component を再実行します。
React の function component では、state / props / context は render の暗黙の入力です。
Flutter でも、Widget は UI を宣言します。
class CounterView extends StatelessWidget {
const CounterView({
required this.count,
super.key,
});
final int count;
@override
Widget build(BuildContext context) {
return Text('$count');
}
}build は count を使って Widget tree を返しています。
ただし、Dart の変数を読んだからといって、その変数の変化を Flutter が自動で追跡するわけではありません。
Flutter では、何が rebuild の契機になるかは別の仕組みで決まります。
代表的には、次のような契機があります。
- 親 Widget が rebuild され、新しい props 相当の値が渡される
StatefulWidgetのState.setStateが呼ばれるInheritedWidget/ Provider / Riverpod など、購読している外部状態が変わるListenable/ChangeNotifierなどの通知を受ける
React のように「component が読んだ値が自動的に依存関係になる」と考えると、Flutter では誤解が生まれます。
Riverpod では、Provider の値を UI に反映したいときに ref.watch を使います。
final counterProvider = StateProvider<int>((ref) => 0);
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}この ref.watch(counterProvider) は、単に値を読むだけではありません。
これは、次の宣言です。
この Widget の
buildはcounterProviderの値に依存している。
counterProviderの値が変わったら、この Widget を rebuild してよい。
React で state や props が component の render 入力になるのに対して、Riverpod では Provider を ref.watch することで、その Provider を build 入力に含めます。
ここが React から来た人にとって最も重要な違いです。
React では、component 内で count を使うと、その count は render の入力です。
function Counter() {
const [count, setCount] = useState(0);
return <span>{count}</span>;
}setCount によって count が変われば、component は再実行されます。
Riverpod では、Provider の値を UI に反映するなら watch します。
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}この比較で大事なのは、ref.watch を useEffect と比較することではありません。
ref.watch は「値の変化に応じて副作用を実行するもの」ではなく、「この Widget の build が依存する入力を宣言するもの」です。
React の世界で無理に近いものを探すなら、次のようなものに近いです。
- Context の値を読む
- Redux / Zustand などの store hook で selector を読む
- TanStack Query / SWR の query hook で非同期状態を読む
useSyncExternalStoreベースの外部 store 購読
ただし、完全な対応物ではありません。
React では hook が component の再実行モデルに統合されていますが、Riverpod では ref.watch を使って Provider の依存を明示します。
React では、event handler から API client や mutation function を呼ぶコードをよく書きます。
たとえば、DI 的に差し込まれた API client を Context から取得して、ボタン押下で呼ぶ例です。
type ApiClient = {
createPost(input: { title: string }): Promise<void>;
};
const ApiClientContext = createContext<ApiClient | null>(null);
function useApiClient(): ApiClient {
const client = useContext(ApiClientContext);
if (!client) {
throw new Error('ApiClientContext is missing');
}
return client;
}
function CreatePostButton({ title }: { title: string }) {
const apiClient = useApiClient();
return (
<button
onClick={async () => {
await apiClient.createPost({ title });
}}
>
create
</button>
);
}テストでは、ApiClientContext.Provider に mock client を渡せます。
const mockApiClient = {
createPost: vi.fn(),
};
render(
<ApiClientContext.Provider value={mockApiClient}>
<CreatePostButton title="hello" />
</ApiClientContext.Provider>
);
await user.click(screen.getByText('create'));
expect(mockApiClient.createPost).toHaveBeenCalledWith({
title: 'hello',
});ここで重要なのは、onClick の中で apiClient が自然に見えていることです。
onClick={async () => {
await apiClient.createPost({ title });
}}なぜ見えるかというと、apiClient は component function の lexical scope にある値であり、event handler はそれを closure として捕まえているからです。
React では、このようなコードが自然に書けます。
- component の render 中に Context / hook から依存を取得する
- event handler がその依存を closure として捕まえる
- click 時に closure 内の依存を呼び出す
TanStack Query の mutation でも同じ構造です。
function CreatePostButton({ title }: { title: string }) {
const mutation = useMutation({
mutationFn: (input: { title: string }) => {
return apiClient.createPost(input);
},
});
return (
<button
onClick={() => {
mutation.mutate({ title });
}}
>
create
</button>
);
}mutation は render 中に作られ、onClick の closure から呼ばれます。
同じことを Flutter / Riverpod で書く場合、API client や repository は Provider として定義することが多いです。
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});ボタン押下で API client を呼ぶなら、event handler の中で ref.read します。
class CreatePostButton extends ConsumerWidget {
const CreatePostButton({
required this.title,
super.key,
});
final String title;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () async {
final apiClient = ref.read(apiClientProvider);
await apiClient.createPost(
title: title,
);
},
child: const Text('create'),
);
}
}ここで ref.watch(apiClientProvider) ではなく ref.read(apiClientProvider) を使う理由は、UI が API client の値を表示しているわけではないからです。
このボタンは、API client の変化に応じて rebuild される必要がありません。
ボタンが押された瞬間に、ProviderContainer から現在の API client を取り出して、createPost を呼べればよいだけです。
したがって、この場面での ref.read は「購読しない読み取り」というより、Provider-managed な依存を event handler に注入するための取り出し口です。
React では、DI された client が component scope にあり、event handler が closure で捕まえます。
Riverpod では、DI された client は ProviderContainer にあり、event handler が ref.read で取り出します。
この違いを表にすると、こうなります。
| 観点 | React | Flutter / Riverpod |
|---|---|---|
| DI の置き場所 | Context Provider、custom hook、module、QueryClient など | Provider / ProviderScope |
| event handler からの参照 | closure で component scope の値を使う | ref.read(provider) で ProviderContainer から取り出す |
| UI 更新の購読 | Context / hook の内部に統合される | ref.watch(provider) で明示 |
| command 実行 | client.createPost()、mutation.mutate() |
ref.read(apiClientProvider).createPost()、ref.read(controllerProvider.notifier).createPost() |
| テスト時の差し替え | Context Provider / mock hook / QueryClient など | ProviderScope(overrides: [...]) |
React の event handler は、render 時点で作られた closure です。
function Component() {
const client = useApiClient();
return (
<button onClick={() => client.doSomething()}>
run
</button>
);
}client は component のローカル変数です。
onClick はそれを closure として保持します。
Flutter の onPressed も closure ではあります。
onPressed: () {
// ここも closure
}しかし、Riverpod の Provider は普通のローカル変数ではありません。
Provider の実体は ProviderScope / ProviderContainer の中にあり、Widget からは WidgetRef を通してアクセスします。
したがって、event handler の中で Provider-managed な依存を使うには、何らかの方法で ref 経由の読み取りが必要です。
onPressed: () {
final client = ref.read(apiClientProvider);
client.doSomething();
}この read は、次の意味を持ちます。
この処理は UI の再構築条件を作りたいのではない。
今このイベントを処理するために、ProviderContainer から依存を取り出したい。
だから watch ではなく read です。
React で TanStack Query を使うと、button click で mutation を呼ぶことがあります。
function CreatePostButton({ title }: { title: string }) {
const createPost = useMutation({
mutationFn: (input: { title: string }) => {
return apiClient.createPost(input);
},
});
return (
<button
disabled={createPost.isPending}
onClick={() => {
createPost.mutate({ title });
}}
>
create
</button>
);
}このコードには、2 種類の関心があります。
createPost.isPendingを UI に反映したい- button click で
createPost.mutate()を呼びたい
React では同じ createPost オブジェクトから両方を扱います。
Riverpod では、この 2 つを watch と read に分けると理解しやすいです。
final createPostControllerProvider =
AsyncNotifierProvider<CreatePostController, void>(
CreatePostController.new,
);
class CreatePostController extends AsyncNotifier<void> {
@override
Future<void> build() async {
// 初期状態
}
Future<void> createPost({
required String title,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final apiClient = ref.read(apiClientProvider);
await apiClient.createPost(
title: title,
);
});
}
}UI 側です。
class CreatePostButton extends ConsumerWidget {
const CreatePostButton({
required this.title,
super.key,
});
final String title;
@override
Widget build(BuildContext context, WidgetRef ref) {
final createPostState = ref.watch(createPostControllerProvider);
final isPending = createPostState.isLoading;
return ElevatedButton(
onPressed: isPending
? null
: () {
ref
.read(createPostControllerProvider.notifier)
.createPost(title: title);
},
child: isPending
? const CircularProgressIndicator()
: const Text('create'),
);
}
}ここで、React の createPost.isPending に相当する UI 状態は watch します。
final createPostState = ref.watch(createPostControllerProvider);一方、React の createPost.mutate() に相当する command 実行は read します。
ref.read(createPostControllerProvider.notifier).createPost(title: title);つまり、Riverpod では次のように分けます。
| TanStack Query 的な役割 | React | Riverpod |
|---|---|---|
| mutation の pending / error / data を UI に反映 | mutation.isPending, mutation.error |
ref.watch(controllerProvider) |
| mutation を起動する | mutation.mutate(input) |
ref.read(controllerProvider.notifier).method(input) |
| mutation 内で API client を呼ぶ | mutationFn から client を呼ぶ |
Notifier 内で ref.read(apiClientProvider) |
この比較は、React Hooks ユーザーにはかなり実用的です。
ref.read は「React に完全対応する hook」ではありませんが、React で event handler から mutate や client method を呼ぶ場面に出てくるものだと考えると理解しやすくなります。
ref.read は Provider の値を読みますが、購読しません。
final count = ref.read(counterProvider);このコードは、その時点の値を取得するだけです。
counterProvider が変わっても、この read を使った Widget が rebuild されるわけではありません。
そのため、UI に表示する値を ref.read で読むのは通常不適切です。
class BadCounterView extends ConsumerWidget {
const BadCounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.read(counterProvider);
return Text('$count');
}
}この Widget は、最初に読んだ値を表示できます。
しかし counterProvider が更新されても、この Widget は counterProvider を購読していないため、更新を契機に rebuild されません。
UI に表示するなら、通常は watch です。
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}ref.read は React の useState、useContext、store hook、query hook にはあまり対応しません。
それらは多くの場合、「読んだ値が UI の再レンダー条件になる」からです。
ref.read は、むしろ event handler や副作用の中で、Provider に対して命令的な処理を起動するために使うものです。
典型的な使い方は、ボタン押下で Notifier のメソッドを呼ぶケースです。
final counterProvider =
NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() {
state++;
}
}UI 側では、表示には watch、操作には read を使います。
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('$count'),
ElevatedButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: const Text('increment'),
),
],
);
}
}ここで役割は明確です。
final count = ref.watch(counterProvider);これは UI の入力です。
counterProvider が変わったら UI を rebuild したいので watch します。
ref.read(counterProvider.notifier).increment();これはイベントによる命令です。
ボタンが押された瞬間に Notifier を取得して increment() を呼びたいだけなので、購読する必要はありません。
実務では、まず次のルールで考えるとよいです。
| 場面 | 使う API |
|---|---|
| UI に値を表示する | ref.watch |
| 値の変化で Widget / Provider を再評価したい | ref.watch |
| ボタン押下で API client / repository を呼びたい | ref.read |
| ボタン押下で Notifier のメソッドを呼びたい | ref.read |
| submit / reload / save などの command を起動したい | ref.read |
| snackbar / navigation / logging など、状態変化に反応した副作用をしたい | ref.listen |
例として、ログインフォームを考えます。
final authControllerProvider =
AsyncNotifierProvider<AuthController, void>(AuthController.new);
class AuthController extends AsyncNotifier<void> {
@override
Future<void> build() async {
// 初期状態
}
Future<void> signIn({
required String email,
required String password,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repository = ref.read(authRepositoryProvider);
await repository.signIn(email: email, password: password);
});
}
}UI 側では、ログイン処理中かどうかは watch します。
final authState = ref.watch(authControllerProvider);ログインボタンを押したときは、read で command を起動します。
onPressed: () {
ref.read(authControllerProvider.notifier).signIn(
email: email,
password: password,
);
}watch は状態を UI に反映するため。
read はイベントから処理を起動するため。
この区別を守ると、Riverpod のコードはかなり読みやすくなります。
React では、状態変化に応じて副作用を起こすときに useEffect を使うことがあります。
useEffect(() => {
if (status === 'success') {
toast('保存しました');
}
}, [status]);Riverpod では、この用途に近いのは ref.listen です。
ref.listen(saveStatusProvider, (previous, next) {
if (next == SaveStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存しました')),
);
}
});ref.listen は、Provider の値が変わったときに callback を実行します。
UI を rebuild するためではなく、副作用を実行するために使います。
副作用とは、たとえば次のようなものです。
- snackbar を出す
- dialog を出す
- navigation する
- analytics event を送る
- logging する
- 外部 service に通知する
これらは build の中で直接実行すべきではありません。
悪い例です。
class SavePage extends ConsumerWidget {
const SavePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(saveStatusProvider);
if (status == SaveStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存しました')),
);
}
return const SaveForm();
}
}build は何度も呼ばれます。
その中で snackbar や navigation を実行すると、意図しない複数回実行やタイミングの問題が起きます。
この場合は、ref.listen に分離します。
class SavePage extends ConsumerWidget {
const SavePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(saveStatusProvider, (previous, next) {
if (next == SaveStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存しました')),
);
}
});
return const SaveForm();
}
}React では useEffect が多用途です。
useEffect(() => {
// 値の変化に応じた副作用
}, [value]);useEffect(() => {
// mount 時の初期化
return () => {
// unmount 時の cleanup
};
}, []);そのため、React ユーザーは Flutter / Riverpod でも「useEffect に対応するものは何か」と考えがちです。
しかし、Flutter / Riverpod では用途ごとに分けた方が正確です。
| やりたいこと | React | Flutter / Riverpod |
|---|---|---|
| 値を UI に反映する | render 中に state / props / hook の値を読む | ref.watch |
| 値の変化で副作用する | useEffect(..., [dep]) |
ref.listen |
| 初回生成時に初期化する | useEffect(..., []) |
initState、Provider の build |
| 破棄時に cleanup する | useEffect の cleanup |
dispose、Provider の lifecycle |
| 描画後に処理する | useEffect / useLayoutEffect の一部用途 |
addPostFrameCallback |
| 非同期データを取得して表示する | useEffect + useState、TanStack Query など |
FutureProvider / AsyncNotifierProvider |
特に、useEffect(..., []) を機械的に addPostFrameCallback に置き換えるのは避けた方がよいです。
addPostFrameCallback は「現在の frame の描画後に callback を実行する」API です。
初回データ取得や状態初期化の一般的な置き場所ではありません。
React では、初回データ取得を useEffect で書くことがあります。
function UserPage() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) return <Spinner />;
return <UserView user={user} />;
}Riverpod では、このような処理を Provider として表現できます。
final userProvider = FutureProvider<User>((ref) async {
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUser();
});
class UserPage extends ConsumerWidget {
const UserPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return user.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => ErrorView(error: error),
data: (user) => UserView(user: user),
);
}
}この設計では、Widget が「初回表示されたので fetch する」と命令しません。
userProvider を watch した結果として非同期処理が走り、状態が AsyncValue として UI に流れます。
React で言えば、useEffect + useState より TanStack Query / SWR のような query hook に近い考え方です。
単純な fetch なら FutureProvider で十分です。
しかし、ユーザー操作で再読み込み・保存・送信などを行う場合は、AsyncNotifierProvider が扱いやすくなります。
final userControllerProvider =
AsyncNotifierProvider<UserController, User>(UserController.new);
class UserController extends AsyncNotifier<User> {
@override
Future<User> build() async {
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUser();
}
Future<void> reload() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repository = ref.read(userRepositoryProvider);
return repository.fetchUser();
});
}
}UI 側です。
class UserPage extends ConsumerWidget {
const UserPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userControllerProvider);
return Column(
children: [
user.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => ErrorView(error: error),
data: (user) => UserView(user: user),
),
ElevatedButton(
onPressed: () {
ref.read(userControllerProvider.notifier).reload();
},
child: const Text('reload'),
),
],
);
}
}ここでも、watch と read の役割は分かれています。
final user = ref.watch(userControllerProvider);これは非同期状態を UI に反映するためです。
ref.read(userControllerProvider.notifier).reload();これはユーザー操作で command を起動するためです。
React では、Context Provider や QueryClient を差し替えてテストすることがあります。
Riverpod では、ProviderScope の overrides で Provider を差し替えます。
class MockApiClient implements ApiClient {
@override
Future<void> createPost({
required String title,
}) async {
// record call
}
}await tester.pumpWidget(
ProviderScope(
overrides: [
apiClientProvider.overrideWithValue(MockApiClient()),
],
child: const MaterialApp(
home: CreatePostButton(title: 'hello'),
),
),
);Widget 内のコードは変わりません。
final apiClient = ref.read(apiClientProvider);
await apiClient.createPost(title: title);ref.read は、テスト時には override 済みの Provider から mock を取得します。
この点では、React で Context Provider に mock client を渡し、event handler からその client を呼ぶ構造とよく似ています。
違うのは、React では closure に client が捕まるのに対し、Riverpod では event handler の実行時に ref.read で ProviderContainer から取得する、という点です。
React のローカル状態は useState で持ちます。
function SearchBox() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}Flutter では、Widget ローカルな mutable state は StatefulWidget の State に持ちます。
class SearchBox extends StatefulWidget {
const SearchBox({super.key});
@override
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
String query = '';
@override
Widget build(BuildContext context) {
return TextField(
onChanged: (value) {
setState(() {
query = value;
});
},
);
}
}ここで注意したいのは、StatefulWidget 自体が mutable state を持つわけではないことです。
状態を持つのは State オブジェクトです。
対応関係はおおよそ次のようになります。
| React | Flutter |
|---|---|
useState の state |
State クラスのフィールド |
setState setter |
Flutter の setState |
| component の再実行 | Widget の rebuild |
| cleanup | dispose |
React の setState は新しい値を React に渡します。
Flutter の setState は「内部状態を変更したので rebuild してほしい」と framework に通知します。
setState(() {
query = value;
});状態変更そのものは、自分でフィールドに代入します。
Riverpod の Provider を Widget から読むには、よく ConsumerWidget を使います。
class UserName extends ConsumerWidget {
const UserName({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return user.when(
loading: () => const Text('loading'),
error: (error, stackTrace) => Text('$error'),
data: (user) => Text(user.name),
);
}
}ConsumerWidget は、build の中で ref.watch / ref.read / ref.listen を使える Widget です。
ただし、controller や focus node のような lifecycle を持つオブジェクトを扱うなら、ConsumerStatefulWidget を使います。
class SearchPage extends ConsumerStatefulWidget {
const SearchPage({super.key});
@override
ConsumerState<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends ConsumerState<SearchPage> {
late final TextEditingController controller;
@override
void initState() {
super.initState();
controller = TextEditingController();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final results = ref.watch(searchResultsProvider);
return Column(
children: [
TextField(controller: controller),
Text('$results'),
],
);
}
}React なら useRef や useEffect cleanup で書くようなものを、Flutter では State のフィールドと dispose で扱います。
React では、props や route parameter に応じて data fetching することがよくあります。
function UserPage({ userId }: { userId: string }) {
const user = useUser(userId);
return <UserView user={user} />;
}Riverpod では、引数付きの Provider を family で表現できます。
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUser(userId);
});
class UserPage extends ConsumerWidget {
const UserPage({
required this.userId,
super.key,
});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider(userId));
return user.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => ErrorView(error: error),
data: (user) => UserView(user: user),
);
}
}TanStack Query で言えば、query key に userId を含める感覚に近いです。
Riverpod の Provider はトップレベルに定義することが多いです。
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository();
});そのため、最初は global variable のように見えるかもしれません。
しかし、Provider の定義は状態そのものではなく、状態や依存関係の作り方の宣言です。
実体は ProviderScope / ProviderContainer の中で管理されます。
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}テストでは override できます。
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: const MyApp(),
);つまり、Provider をトップレベルに書くことは、「アプリ全体に mutable global state を置く」という意味ではありません。
React で言えば、Context の定義や dependency injection token をモジュールスコープに置くことに近いです。
final count = ref.read(counterProvider);
return Text('$count');この場合、Provider が更新されても UI が更新されません。
表示に使うなら watch します。
final count = ref.watch(counterProvider);
return Text('$count');final notifier = ref.watch(counterProvider.notifier);
ElevatedButton(
onPressed: () {
notifier.increment();
},
child: const Text('increment'),
);意図が「ボタンが押されたときに command を呼ぶ」だけなら、購読は不要です。
ElevatedButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: const Text('increment'),
);final status = ref.watch(saveStatusProvider);
if (status == SaveStatus.success) {
Navigator.of(context).pushNamed('/done');
}navigation は UI の計算ではなく副作用です。
ref.listen に分離します。
ref.listen(saveStatusProvider, (previous, next) {
if (next == SaveStatus.success) {
Navigator.of(context).pushNamed('/done');
}
});@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(userProvider.notifier).load();
});
return const UserView();
}build は何度も呼ばれます。
そのたびに post-frame callback を登録すると、意図しない複数回実行の原因になります。
初回 fetch なら Provider に寄せる。
lifecycle が必要なら initState を使う。
描画後でなければならない UI 操作だけ addPostFrameCallback を検討する。
この順で考えると安全です。
| React での発想 | Flutter / Riverpod での考え方 |
|---|---|
| state / props が変わると component が再実行される | Flutter では rebuild の契機を framework / state management が管理する |
| Context や store hook の値を UI に出す | ref.watch(provider) で Provider を build 入力にする |
| event handler から DI 済み client を呼ぶ | ref.read(clientProvider) で Provider-managed な依存を取得する |
| button click で mutation を起動する | ref.read(controllerProvider.notifier).method() で command を起動する |
| mutation の pending / error を UI に出す | ref.watch(controllerProvider) で AsyncValue を UI に反映する |
useState で local state を持つ |
StatefulWidget の State に持つ |
useReducer で状態遷移をまとめる |
Notifier / AsyncNotifier に状態と操作をまとめる |
useEffect(..., [value]) で副作用する |
ref.listen(provider, ...) を検討する |
useEffect(..., []) で初回 fetch する |
FutureProvider / AsyncNotifierProvider の build に寄せることを検討する |
| cleanup 付き effect | dispose、または Provider の lifecycle を使う |
| custom hook | Widget、Provider、Notifier、普通の関数に分ける |
React Hooks の知識は Flutter / Riverpod を学ぶときに役立ちます。
ただし、hook と API を 1 対 1 に対応させるより、UI の再構築条件、依存の取得、command の起動、副作用の置き場所を分けて考える方が正確です。
React では、state / props / context が component の暗黙の render 入力になります。
Riverpod では、Provider を ref.watch したときに、その Provider が Widget / Provider の明示的な入力になります。
React では、event handler が component scope にある API client や mutation function を closure で捕まえます。
Riverpod では、Provider-managed な API client や Notifier を event handler から使うとき、ref.read で ProviderContainer から取り出します。
ref.read は、React の購読系 hook には対応しません。
これは UI の再構築条件を作るものではなく、イベントや副作用の中から Provider に命令的にアクセスするための API です。
ref.listen は、Provider の変化に反応して副作用を実行するための API です。
snackbar、navigation、logging などは build の中ではなく、listen で扱うと整理しやすくなります。
最終的には、次の分け方を覚えるのが実用的です。
- UI に表示する値を読む:
ref.watch - イベントで Provider-managed な依存や command を使う:
ref.read - 状態変化に反応して副作用する:
ref.listen - Widget lifecycle に乗る:
initState/dispose - 非同期データを状態として扱う:
FutureProvider/AsyncNotifierProvider
この区別ができると、React Hooks の直感を活かしつつ、Flutter / Riverpod らしい設計に移行しやすくなります。
- React: Synchronizing with Effects
https://react.dev/learn/synchronizing-with-effects - React: You Might Not Need an Effect
https://react.dev/learn/you-might-not-need-an-effect - React: useEffect reference
https://react.dev/reference/react/useEffect - TanStack Query: Mutations
https://tanstack.com/query/latest/docs/framework/react/guides/mutations - Flutter: StatefulWidget class
https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html - Flutter: SchedulerBinding.addPostFrameCallback
https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html - Riverpod: Refs
https://riverpod.dev/docs/concepts2/refs - Riverpod: Family
https://riverpod.dev/docs/concepts2/family - Riverpod: FutureProvider
https://docs-v2.riverpod.dev/docs/providers/future_provider