Skip to content

Instantly share code, notes, and snippets.

@matarillo
Last active May 24, 2026 08:44
Show Gist options
  • Select an option

  • Save matarillo/de52c98227ae8a559c92c60aee035da5 to your computer and use it in GitHub Desktop.

Select an option

Save matarillo/de52c98227ae8a559c92c60aee035da5 to your computer and use it in GitHub Desktop.

React Hooks ユーザーのための Flutter / Riverpod 入門

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 の watchreadlisten、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 つです。

  1. ref.watch は「この Provider を build の入力にする」という宣言
  2. ref.read は UI の購読ではなく、event handler や副作用の中から Provider-managed な依存や command を取り出すための API
  3. ref.listen は Provider の変化に反応して snackbar / navigation / logging などの副作用を起こすための API
  4. React の event handler は closure で依存を捕まえるが、Riverpod では Provider にある依存を ref.read で明示的に取得する

React は state / props を暗黙に render 入力にする

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 結果は、countstep に依存しています。

ただし、React では次のように「count を購読します」「step を購読します」とは書きません。
useState の state や props は、component の入力として React に管理されています。

setCount が呼ばれれば、React は component を再実行します。
親から渡される step が変われば、React は component を再実行します。

React の function component では、state / props / context は render の暗黙の入力です。


Flutter の build はただの関数実行に近い

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');
  }
}

buildcount を使って Widget tree を返しています。

ただし、Dart の変数を読んだからといって、その変数の変化を Flutter が自動で追跡するわけではありません。
Flutter では、何が rebuild の契機になるかは別の仕組みで決まります。

代表的には、次のような契機があります。

  • 親 Widget が rebuild され、新しい props 相当の値が渡される
  • StatefulWidgetState.setState が呼ばれる
  • InheritedWidget / Provider / Riverpod など、購読している外部状態が変わる
  • Listenable / ChangeNotifier などの通知を受ける

React のように「component が読んだ値が自動的に依存関係になる」と考えると、Flutter では誤解が生まれます。


Riverpod の ref.watch は build 入力の明示

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 の buildcounterProvider の値に依存している。
counterProvider の値が変わったら、この Widget を rebuild してよい。

React で state や props が component の render 入力になるのに対して、Riverpod では Provider を ref.watch することで、その Provider を build 入力に含めます。

ここが React から来た人にとって最も重要な違いです。


ref.watch と 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.watchuseEffect と比較することではありません。

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 は closure で依存を捕まえる

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 では、このようなコードが自然に書けます。

  1. component の render 中に Context / hook から依存を取得する
  2. event handler がその依存を closure として捕まえる
  3. 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 では event handler から Provider を ref.read する

同じことを 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: [...])

ref.read が必要になる理由

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 です。


ref.read は TanStack Query の mutate 呼び出しに近い場面で出てくる

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 種類の関心があります。

  1. createPost.isPending を UI に反映したい
  2. button click で createPost.mutate() を呼びたい

React では同じ createPost オブジェクトから両方を扱います。

Riverpod では、この 2 つを watchread に分けると理解しやすいです。

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 は React の「購読系 hook」には対応しない

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 の useStateuseContext、store hook、query hook にはあまり対応しません。
それらは多くの場合、「読んだ値が UI の再レンダー条件になる」からです。

ref.read は、むしろ event handler や副作用の中で、Provider に対して命令的な処理を起動するために使うものです。


ref.read はイベント・副作用の中で使う

典型的な使い方は、ボタン押下で 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() を呼びたいだけなので、購読する必要はありません。


watchread の使い分け

実務では、まず次のルールで考えるとよいです。

場面 使う 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 のコードはかなり読みやすくなります。


ref.listen は状態変化に反応する副作用

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();
  }
}

useEffect と比較するなら watch ではなく listen や lifecycle

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 です。
初回データ取得や状態初期化の一般的な置き場所ではありません。


初回データ取得は Provider に寄せる

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 する」と命令しません。
userProviderwatch した結果として非同期処理が走り、状態が AsyncValue として UI に流れます。

React で言えば、useEffect + useState より TanStack Query / SWR のような query hook に近い考え方です。


command を持つ非同期状態には AsyncNotifierProvider

単純な 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'),
        ),
      ],
    );
  }
}

ここでも、watchread の役割は分かれています。

final user = ref.watch(userControllerProvider);

これは非同期状態を UI に反映するためです。

ref.read(userControllerProvider.notifier).reload();

これはユーザー操作で command を起動するためです。


テスト時の差し替え

React では、Context Provider や QueryClient を差し替えてテストすることがあります。

Riverpod では、ProviderScopeoverrides で 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 から取得する、という点です。


StatefulWidgetuseState

React のローカル状態は useState で持ちます。

function SearchBox() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

Flutter では、Widget ローカルな mutable state は StatefulWidgetState に持ちます。

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;
});

状態変更そのものは、自分でフィールドに代入します。


ConsumerWidgetConsumerStatefulWidget

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 なら useRefuseEffect cleanup で書くようなものを、Flutter では State のフィールドと dispose で扱います。


family は引数付き Provider

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 を含める感覚に近いです。


Provider は global variable ではない

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 をモジュールスコープに置くことに近いです。


よくある失敗

UI 表示に read を使う

final count = ref.read(counterProvider);
return Text('$count');

この場合、Provider が更新されても UI が更新されません。
表示に使うなら watch します。

final count = ref.watch(counterProvider);
return Text('$count');

command 呼び出しに watch を使う

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'),
);

build の中で副作用を実行する

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');
  }
});

何でも addPostFrameCallback に入れる

@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 Hooks からの変換早見表

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 を持つ StatefulWidgetState に持つ
useReducer で状態遷移をまとめる Notifier / AsyncNotifier に状態と操作をまとめる
useEffect(..., [value]) で副作用する ref.listen(provider, ...) を検討する
useEffect(..., []) で初回 fetch する FutureProvider / AsyncNotifierProviderbuild に寄せることを検討する
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 らしい設計に移行しやすくなります。


参考資料

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment