Flutter HooksのuseReducerによる複雑なWidgetの実装テクニック

2023/10/18に公開

FlutterにおいてWidgetの状態管理やロジックの実行を効率的に行う手段として、Flutter HooksのuseReducerというHookが存在します。しかし実際にはこれまでその活用があまり見受けられませんでした。それはおそらく、useReducerの挙動や活用方法について十分に理解や知識が広まっていないためだと思われます。その利用方法を身に付けることで開発者は寄り幅広い状態管理が可能になり、コードの効率化も期待できます。そこでこの記事ではuseReducerの使い方を実際のコードとともに解説し、さらに効率的な実装テクニックを提示したいと思います。

useReducerとは?

useReducerは、Flutter Hooksというライブラリが提供するHookの一つです。このuseReducerは、特定の状態に対して行われる一連のアクションを管理することに特化しています。
一般的に、状態管理には状態変数とその状態を更新するためのメソッドが必要です。しかし、アプリケーションが成長し、状態が複雑になると、それを更新するメソッドも同様に複雑になる傾向があります。ここでuseReducerは威力を発揮します。useReducerは、異なるアクションに対して一つのメソッド(reducer関数)を使用して状態を管理し、それによりコードがより整理され、可読性が高まります。

これらの特徴から具体的な活用場面として以下のようなケースが挙げられます。

  1. 複数の関連する値を一緒に更新する必要がある場合: useReducerは、関連する複数の値を一緒にアップデートする必要があるときに役立ちます。それぞれの値に対して useStateを使用するのではなく、useReducerを使用して全ての関連する値の更新を一度にまとめることができます。

  2. 複雑な状態のロジックを管理したい場合: useReducerを使用すると、複雑な状態変化のロジックをリデューサ関数内部 にカプセル化できます。これにより、コードは読みやすく理解しやすくなります。

  3. 同じ状態更新ロジックを多くのコンポーネントで再利用したい場合: reducer関数は純粋であるべきなので、その外部に依存せず、複数のコンポーネント間で再利用可能です。これにより、コードの重複を避けられます。

  4. テストしやすさを求める場合: reducerを所与の状態とアクションに対して一貫した新しい状態を生成する純粋関数として実装することで、単体テストが容易になります。

非useReducerの例

ここでは複雑な状態とロジックをuseStateで管理する例を挙げます。

具体的には以下の機能を持っています。

  1. ドロップダウン1選択(再選択で文字列が初期化される)
  2. 文字列入力(文字列検査機能を持つ)
  3. ドロップダウン2選択
  4. 結果の表示
  5. リセット
import 'package:flutter/material.dart';  
import 'package:flutter_hooks/flutter_hooks.dart';  
import 'package:use_reducer_example/reducer.dart';  
  
final itemsForDropdown1 = ['A', 'B', 'C'];  
final itemsForDropdown2 = ['1', '2', '3'];  
  
enum Stage { dropdown1, textInput, dropdown2, finished }  
  
class MyFormUseStateView extends HookWidget {  
  const MyFormUseStateView({super.key});  
  
    
  Widget build(BuildContext context) {  
    final dropdown1Value = useState<String?>(null);  
    final textInputController = useTextEditingController();  
    final dropdown2Value = useState<String?>(null);  
    final stage = useState<Stage>(Stage.dropdown1);  
    final formKey = useState(GlobalKey<FormState>());  
  
    return Form(  
      key: formKey.value,  
      child: Center(  
        child: Column(  
          children: <Widget>[  
            if (stage.value == Stage.dropdown1 || stage.value == Stage.textInput || stage.value == Stage.dropdown2)  
              DropdownButton<String>(  
                value: dropdown1Value.value,  
                items:  
                    itemsForDropdown1.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
                onChanged: (value) {  
                  dropdown1Value.value = value;  
                  textInputController.clear();  
                  stage.value = Stage.textInput;  
                },  
              ),  
            if (stage.value == Stage.textInput)  
              Column(  
                children: <Widget>[  
                  TextFormField(  
                    controller: textInputController,  
                    validator: (value) => validate(value),  
                  ),  
                  ElevatedButton(  
                    onPressed: () {  
                      if (formKey.value.currentState!.validate()) {  
                        stage.value = Stage.dropdown2;  
                      }  
                    },  
                    child: const Text('Submit'),  
                  ),  
                ],  
              ),  
            if (stage.value == Stage.dropdown2) ...[  
              Text(textInputController.text),  
              DropdownButton<String>(  
                value: dropdown2Value.value,  
                items:  
                    itemsForDropdown2.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
                onChanged: (value) {  
                  dropdown2Value.value = value;  
                  stage.value = Stage.finished;  
                },  
              ),  
            ],  
            if (stage.value == Stage.finished)  
              Column(  
                children: <Widget>[  
                  Text('Dropdown 1: ${dropdown1Value.value}'),  
                  Text('Text Input: ${textInputController.text}'),  
                  Text('Dropdown 2: ${dropdown2Value.value}'),  
                  ElevatedButton(  
                    onPressed: () {  
                      dropdown1Value.value = null;  
                      textInputController.clear();  
                      dropdown2Value.value = null;  
                      stage.value = Stage.dropdown1;  
                    },  
                    child: const Text('Reset'),  
                  ),  
                ],  
              ),  
          ],  
        ),  
      ),  
    );  
  }  
}

各Widgetが複数の状態を参照し、ロジックを実行しています。おそらくこれ以上状態やロジックが増えるとViewModelの導入を検討したり、Riverpodなどのライブラリーで状態を管理したくなるのではないでしょうか。

useReducerの例

useStateでの実装をuseReducerで書き換えます。

まずWidgetのコードを示します。

import 'package:flutter/material.dart';  
import 'package:flutter_hooks/flutter_hooks.dart';  
import 'package:use_reducer_example/reducer.dart';  
  
final itemsForDropdown1 = ['A', 'B', 'C'];  
final itemsForDropdown2 = ['1', '2', '3'];  
  
class MyFormUseReducerView extends HookWidget {  
  const MyFormUseReducerView({super.key});  
  
    
  Widget build(BuildContext context) {  
    final MyFormStore store = useReducer(  
      reducer,  
      initialState: MyFormState(GlobalKey<FormState>(), useTextEditingController()),  
      initialAction: const ResetAction(),  
    );  
  
    return Form(  
      key: store.state.formKey,  
      child: Center(  
        child: Column(  
          children: [  
            if (store.state case MyFormState(:final dropdown1Value) when !store.state.isFinished)  
              DropdownButton<String>(  
                value: dropdown1Value,  
                items:  
                    itemsForDropdown1.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
                onChanged: (value) => store.dispatch(Dropdown1ChangedAction(value!)),  
              ),  
            if (store.state case MyFormState(:final dropdown1Value?, textInput: null)) ...[  
              TextFormField(  
                controller: store.state.textInputController,  
                validator: (value) => (store..dispatch(const ValidateTextInputAction())).state.error,  
              ),  
              ElevatedButton(  
                onPressed: () => store.state.formKey.currentState!.validate(),  
                child: const Text('Submit'),  
              ),  
            ],  
            if (store.state case MyFormState(textInput: final textInput?, dropdown2Value: null)) ...[  
              Text(textInput),  
              DropdownButton<String>(  
                value: store.state.dropdown2Value,  
                items:  
                    itemsForDropdown2.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
                onChanged: (value) => store.dispatch(Dropdown2ChangedAction(value!)),  
              ),  
            ],  
            if (store.state  
                case MyFormState(:final dropdown1Value?, :final textInput?, error: null, :final dropdown2Value?)) ...[  
              Text('Dropdown 1: $dropdown1Value'),  
              Text('Text Input: $textInput'),  
              Text('Dropdown 2: $dropdown2Value'),  
              ElevatedButton(  
                onPressed: () => store.dispatch(const ResetAction()),  
                child: const Text('Reset'),  
              ),  
            ],  
          ],  
        ),  
      ),  
    );  
  }  
}

状態はstore.stateにまとめられ、ロジックの実行もstore.dispatchを実行するだけになっています。

state、action、reducer関数の実装を見てみましょう。

import 'package:equatable/equatable.dart';  
import 'package:flutter/material.dart';  
import 'package:flutter_hooks/flutter_hooks.dart';  
  
class MyFormState extends Equatable {  
  const MyFormState(  
    this.formKey,  
    this.textInputController, [  
    this.dropdown1Value,  
    this.textInput,  
    this.error,  
    this.dropdown2Value,  
  ]);  
  
  final TextEditingController textInputController;  
  final GlobalKey<FormState> formKey;  
  
  final String? dropdown1Value;  
  final String? textInput;  
  final String? error;  
  final String? dropdown2Value;  
  
  bool get isFinished => dropdown1Value != null && textInput != null && dropdown2Value != null;  
  
  MyFormState copyWith({  
    GlobalKey<FormState>? formKey,  
    String? dropdown1Value,  
    String? textInput,  
    String? error,  
    String? dropdown2Value,  
  }) {  
    return MyFormState(  
      formKey ?? this.formKey,  
      textInputController,  
      dropdown1Value ?? this.dropdown1Value,  
      textInput ?? this.textInput,  
      error ?? this.error,  
      dropdown2Value ?? this.dropdown2Value,  
    );  
  }  
  
    
  List<Object?> get props => [formKey, textInputController, dropdown1Value, textInput, error, dropdown2Value];  
}  
  
sealed class MyFormAction {  
  const MyFormAction();  
}  
  
class Dropdown1ChangedAction extends MyFormAction {  
  const Dropdown1ChangedAction(this.value);  
  
  final String value;  
}  
  
class ValidateTextInputAction extends MyFormAction {  
  const ValidateTextInputAction();  
}  
  
class Dropdown2ChangedAction extends MyFormAction {  
  const Dropdown2ChangedAction(this.value);  
  
  final String value;  
}  
  
class ResetAction extends MyFormAction {  
  const ResetAction();  
}  
  
typedef MyFormStore = Store<MyFormState, MyFormAction>;  
  
MyFormState reducer(MyFormState state, MyFormAction action) => switch ((state, action)) {  
      (_, Dropdown1ChangedAction(:final value)) => MyFormState(  
          state.dropdown1Value == null ? state.formKey : GlobalKey(),  
          state.textInputController..clear(),  
          value,  
        ),  
      (MyFormState(textInput: null), ValidateTextInputAction()) => switch (validate(state.textInputController.text)) {  
          null => MyFormState(  
              state.formKey,  
              state.textInputController,  
              state.dropdown1Value,  
              state.textInputController.text,  
            ),  
          final error => state.copyWith(error: error),  
        },  
      (MyFormState(:final textInput?, error: null, dropdown2Value: null), Dropdown2ChangedAction(:final value)) =>  
        state.copyWith(dropdown2Value: value),  
      (_, ResetAction()) => MyFormState(GlobalKey<FormState>(), state.textInputController..clear()),  
      (_, _) => throw Exception('Invalid state/action combination: $state, $action'),  
    };  
  
String? validate(String? value) {  
  if (value == null || value.isEmpty) {  
    return 'Please enter some text';  
  } else if (value.length < 3) {  
    return 'Please enter at least 3 characters';  
  } else if (value.length > 10) {  
    return 'Please enter at most 10 characters';  
  } else {  
    return null;  
  }  
}

複数の状態が存在するとき、それらをuseStateを用いてひとつひとつ管理する代わりに、MyFormStateという形でまとめて管理することができます。ユーザーのアクションは、MyFormActionというsealed classを継承した複数のクラスを使って表現されます。これにより、reducer関数は現在の状態とアクションを受け取り、次の状態を生成します。

Flutterでは、Widgetが複雑になるとViewModelの導入や、Riverpodなどのライブラリでの状態管理が一般的に行われます。しかし、既にFlutter Hooksを利用しているプロジェクトでは、useReducerの使用が有益とされることが多いです。これにより、useTextEditingControllerなどの利用時に、ライフタイム管理の必要性が減少します。

しかし、useReducerの使用には注意が必要です。特に、storeの取り扱いが課題となることがあります。複雑なWidgetは通常複数の子孫Widgetに分割したいと考えますが、その際にstoreを子孫Widgetにバケツリレーで渡すことになります。さらに、storeが更新される度に子孫Widgetすべてがリビルドされる可能性があります。ですから、単純にuseReducerを利用しただけでは、必ずしも効率的なWidgetツリーを作成しやすいとは限りません。

これらの問題を解決し、より効率的な状態管理を図るためのテクニックを紹介します。

InheritedModelとInheritedWidgetによるstoreの参照

storeをバケツリレーすることなくリビルドも抑制するには、InheritedModelとInheritedWidgetを利用してstore.stateとstore.dispatchを子孫Widgetから参照できるようにします。

import 'package:flutter/material.dart';  
import 'package:flutter_hooks/flutter_hooks.dart';  
  
typedef Aspect<State> = bool Function(State oldState, State newState);  
typedef ListenState<State, Selected> = Selected Function(State state);  
  
bool unaspect(dynamic oldState, dynamic newState) => false;  
  
class StoreInjector<State, Action> extends StatelessWidget {  
  const StoreInjector({super.key, required this.store, required this.child});  
  
  final Widget child;  
  
  final Store<State, Action> store;  
  
    
  Widget build(BuildContext context) => StateModel<State>(  
        store: store,  
        child: Dispatcher<Action>(  
          store.dispatch,  
          child: child,  
        ),  
      );  
}  
  
class StateModel<State> extends InheritedModel<Aspect<dynamic>> {  
  StateModel({super.key, required this.store, required super.child}) : _oldState = store.state;  
  
  final Store<State, dynamic> store;  
  final State _oldState;  
  
  static State of<State>(BuildContext context, {Aspect? aspect}) =>  
      InheritedModel.inheritFrom<StateModel<State>>(context, aspect: aspect)!.store.state;  
  
  static Select selectOf<State, Select>(  
    BuildContext context, {  
    Aspect? aspect,  
    required ListenState<State, Select> select,  
  }) =>  
      select(  
        InheritedModel.inheritFrom<StateModel<State>>(context,  
                aspect: (oldState, newState) => select(oldState) != select(newState))!  
            .store  
            .state,  
      );  
  
    
  bool updateShouldNotify(covariant StateModel<State> oldWidget) => store.state != oldWidget._oldState;  
  
    
  bool updateShouldNotifyDependent(covariant StateModel<State> oldWidget, Set<Aspect<State>> dependencies) =>  
      dependencies.any((aspect) => aspect(store.state, oldWidget._oldState));  
}  
  
class Dispatcher<Action> extends InheritedWidget {  
  const Dispatcher(this.dispatch, {super.key, required super.child});  
  
  static Dispatcher<Action> of<Action>(BuildContext context) =>  
      context.getElementForInheritedWidgetOfExactType<Dispatcher<Action>>()!.widget as Dispatcher<Action>;  
  
  final void Function(Action action) dispatch;  
  
    
  bool updateShouldNotify(covariant InheritedWidget oldWidget) => true;  
}

このような部品を作ることでstateとdispatchを直接参照できるようになります。

これらを組み込んだコードを以下に示します。

import 'package:flutter/material.dart';  
import 'package:flutter_hooks/flutter_hooks.dart';  
import 'package:use_reducer_example/store_injector.dart';  
  
import 'reducer.dart';  
  
final itemsForDropdown1 = ['A', 'B', 'C'];  
final itemsForDropdown2 = ['1', '2', '3'];  
  
class MyFormUseReducerWithStoreInjectorView extends HookWidget {  
  const MyFormUseReducerWithStoreInjectorView({super.key});  
  
    
  Widget build(BuildContext context) {  
    final MyFormStore store = useReducer(  
      reducer,  
      initialState: MyFormState(GlobalKey<FormState>(), useTextEditingController()),  
      initialAction: const ResetAction(),  
    );  
    return StoreInjector(store: store, child: const _Form());  
  }  
}  
  
class _Form extends StatelessWidget {  
  const _Form();  
  
    
  Widget build(BuildContext context) {  
    final state = StateModel.of<MyFormState>(context);  
  
    return Form(  
      key: state.formKey,  
      child: Center(  
        child: Column(  
          children: [  
            if (!state.isFinished) const _Dropdown1View(),  
            if (state case MyFormState(:final dropdown1Value?, textInput: null)) ...[  
              const _TextInputField(),  
              const _SubmitTextInputButton(),  
            ],  
            if (state case MyFormState(textInput: final textInput?, dropdown2Value: null)) ...[  
              Text(textInput),  
              const _Dropdown2View(),  
            ],  
            if (state  
                case MyFormState(:final dropdown1Value?, :final textInput?, error: null, :final dropdown2Value?)) ...[  
              Text('Dropdown 1: $dropdown1Value'),  
              Text('Text Input: $textInput'),  
              Text('Dropdown 2: $dropdown2Value'),  
              const _ResetButton(),  
            ],  
          ],  
        ),  
      ),  
    );  
  }  
}  
  
class _Dropdown1View extends StatelessWidget {  
  const _Dropdown1View({super.key});  
  
    
  Widget build(BuildContext context) {  
    final dropdown1Value = StateModel.selectOf<MyFormState, String?>(context, select: (state) => state.dropdown1Value);  
  
    return DropdownButton<String>(  
      value: dropdown1Value,  
      items: itemsForDropdown1.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
      onChanged: (value) => Dispatcher.of<MyFormAction>(context).dispatch(Dropdown1ChangedAction(value!)),  
    );  
  }  
}  
  
class _TextInputField extends StatelessWidget {  
  const _TextInputField({super.key});  
  
    
  Widget build(BuildContext context) {  
    final textInputController =  
        StateModel.selectOf<MyFormState, TextEditingController>(context, select: (state) => state.textInputController);  
  
    return TextFormField(  
      controller: textInputController,  
      validator: (value) {  
        Dispatcher.of<MyFormAction>(context).dispatch(const ValidateTextInputAction());  
        return StateModel.of<MyFormState>(context).error;  
      },  
    );  
  }  
}  
  
class _SubmitTextInputButton extends StatelessWidget {  
  const _SubmitTextInputButton({super.key});  
  
    
  Widget build(BuildContext context) {  
    return ElevatedButton(  
      onPressed: () => StateModel.of<MyFormState>(context).formKey.currentState!.validate(),  
      child: const Text('Submit'),  
    );  
  }  
}  
  
class _Dropdown2View extends StatelessWidget {  
  const _Dropdown2View({super.key});  
  
    
  Widget build(BuildContext context) {  
    final dropdown2Value = StateModel.selectOf<MyFormState, String?>(context, select: (state) => state.dropdown2Value);  
  
    return DropdownButton<String>(  
      value: dropdown2Value,  
      items: itemsForDropdown2.map((item) => DropdownMenuItem<String>(value: item, child: Text(item))).toList(),  
      onChanged: (value) => Dispatcher.of<MyFormAction>(context).dispatch(Dropdown2ChangedAction(value!)),  
    );  
  }  
}  
  
class _ResetButton extends StatelessWidget {  
  const _ResetButton({super.key});  
  
    
  Widget build(BuildContext context) {  
    return ElevatedButton(  
      onPressed: () => Dispatcher.of<MyFormAction>(context).dispatch(const ResetAction()),  
      child: const Text('Reset'),  
    );  
  }  
}

Storeインスタンスを作成し、それをStoreInjectorに渡すことで、全ての子孫である_Form Widget内でstateとdispatchを直接参照できるようになります。さらに、StateModel.selectOfメソッドを利用することにより、ステートの特定の部分が変更された時だけ、該当のWidgetがリビルドされるように制御できます。

FlutterのInheritedModelやInheritedWidgetをuseReducerに組み合わせることで、通常のバケツリレー問題や過度なリビルド問題を解決することが可能になります。これらの設計パターンにより、アプリケーションのパフォーマンスと効率性が向上します。

まとめ

Flutter HooksのuseReducerは、Widgetの状態管理を効果的に行うための強力な道具です。複雑な状態変更のロジックを一箇所にまとめ、これをシンプルなインターフェースで呼び出すことができます。また、その一貫性と規則性は、コードの可読性を大幅に向上させ、テストの効率化にも寄与します。
しかし、useReducerの使用には注意が必要であり、利用する際はアプリケーションの特定のニーズを考慮する必要があります。特に、データフローと子孫Widgetsのリビルドの頻度について慎重な計画を立てる必要があります。
より高度な状態管理のためには、useReducerをFlutterのInheritedModelやInheritedWidgetと組み合わせることが役立ちます。これらのテクニックを駆使することで、より効率的で、パフォーマンスの高い、そして保守性の高いFlutterアプリケーションの開発が可能となります。

合同会社CAPH TECH

Discussion