7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterでMobX.dartを使ってみる

Last updated at Posted at 2026-01-21

MobX.dartはDart用の状態管理ライブラリで、React用のMobXの設計思想をもとにしています。

MobXの特徴

MobXの基本的なコンセプトは以下の3つです。

  • observable (リアクティブな値)
  • action (リアクティブな値の更新)
  • reaction (更新をトリガーにした処理)

使い方をRiverpodと比較しつつ紹介します。

状態の参照と更新

状態はobservable、状態の更新をactionと定義しています。
observableの値を参照するのにriverpodでいうref.watchやref.readを経由する必要はありません。
更新を通知するには runInAction()を使います。

final anyState = Observable('');
Widget build(BuildContext context) {
  return Column(
    children: [
      // 値を読む。リアクティブに反応しない
      Text(anyState.value),
      // 値を読む。更新されたらリビルドされる
      Observer(builder:(_) => Text(anyState.value)),
      ElevatedButton(
        onPressed: () {
          // 状態を更新して、observerに通知する
          runInAction(() => anyState.value = DateTime.now().toString());
        },
        child: const Text('Update'),
      )
    ],
  );
}

リアクティブな処理の実行

状態の変化によって処理を実行するのはMobXではreactionと定義しています。
riverpodではref.listenですが、reaction、autorun、whenというラッパーを使います。
これらのラッパーで作ったreactionはアプリケーションコードで破棄を管理する必要があります。

final dispose = reaction(
  (_) => anyState.value,
  (stateValue) => debugPrint(stateValue),
);

dispose();

ReactionBuilderウィジェットから使う場合は管理不要です。

return ReactionBuilder(
  builder: (_) {
    return reaction(
      (_) => anyState.value,
      (stateValue) => debugPrint(stateValue),
    );
  },
  child: Text('Hogehoge'),
);

autoDisposeはない

riverpodの便利(たまに罠)な機能としてライフサイクル管理がありますが、MobXにはありません。
状態への参照がなくなった時点で破棄したい場合は、アプリケーションコードとして実装する必要があります。

disposeの戦略としてはいくつかありそうです。

1. StatefulWidgetのStateにObservableなプロパティを定義する

一番簡単ですが、状態管理ライブラリを使わないときと同じような面倒くささが発生します。

2. 手動で初期化・破棄

個人的な感覚ですが、autoDisposeな状態の多くは使い始める起点になる画面があるように思います。
その起点になる画面でObservableを状態を初期化・破棄すれば上手くいきそうな予感があります。

3. 参照カウントを実装する

control_coreにあるReferenceCounter mixinを使うとそこそこ上手くできそうな感じはしますが、バグがあったときに頭が痛くなりそうです。

Observableの定義

実際の開発では適切な粒度のObservableとActionをまとめたクラスを定義すると思います。
ここでボイラープレートを避けるため、mobx_codegenを導入することになります。

mobx_codegenを使ったクラス定義ではアノテーションを使って、Observable・Actionを定義します。
計算で値を決めるリアクティブプロパティを作ることもできます。

@observable ... リアクティブなプロパティ
@compute ... 合成されたリアクティブなプロパティ
@action ... アクション相当のメソッド

page.dart
import 'package:mobx/mobx.dart';

part 'score.g.dart';

/// スコア
class Score = ScoreBase with _$Score;

abstract class ScoreBase with Store {

  /// スコア
  @observable
  var score = 0;

  @observable
  var highScore = 0;

  /// 初期化する
  @action
  void init() {
    score = 0;
  }

  /// スコアを更新する
  @action
  void addPoint(int point) {
    score += point;
    if(score > highScore) {
      highScore = score;
    }
  }
}

_$ScoreクラスがScoreの実体になっていて、通知やアクションの実行がうまくプログラマから隠蔽されています。

score.g.dartの一部抜粋
@override
int get score {
  _$scoreAtom.reportRead();
  return super.score;
}

@override
set score(int value) {
  _$scoreAtom.reportWrite(value, super.score, () {
    super.score = value;
  });
}

@override
void addPoint(int point) {
  final _$actionInfo = _$ScoreBaseActionController.startAction(
    name: 'ScoreBase.addPoint',
  );
  try {
    return super.addPoint(point);
  } finally {
    _$ScoreBaseActionController.endAction(_$actionInfo);
  }
}

非同期の取り扱い

ObserverbleFutureを使います。riverpodとriverpod_generatorを使ったFutureProviderとAsyncValueのほうがちょっとスッキリしている感じがしますね。

import 'package:mobx/mobx.dart';

part 'count.g.dart';


class Count = CountBase with _$Count;

abstract class CountBase with Store {

  /// カウント値を非同期で保持するObservableFuture
  @observable
  var value = ObservableFuture.value(0);

  /// ロード中かどうか
  @computed
  bool get isLoading => value.isLoading;

  @action
  Future<void> increment() async {
    value = ObservableFuture(_incrementValue());
  }

  Future<int> _incrementValue() async {
    await Future.delayed(const Duration(milliseconds: 100));
    return (value.value ?? 0) + 1;
  }
}

extension ObservableFutureExtension<T> on ObservableFuture<T> {
  bool get isLoading => status == FutureStatus.pending;
}

何か作ってみる

メモアプリのようなものを作ってみます。やることはToDoアプリとほぼ同じです。

  • メモ一覧
  • メモ編集

memo.gif

ソースコード

以下のパッケージを追加します。

UUIDはmobxとは無関係で、メモページの識別子を生成するために組み込んでいます。

$ flutter pub add dev:build_runner dev:mobx_codegen mobx flutter_mobx uuid

以下が実行可能なソースコードです。
mobx_codegenを使っているので、ビルド前にbuild_runnerを実行してpage.g.dartとmemo.g.dartを生成しておきます。

$ dart pub run build_runner build
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:mobx_test/memo.dart';

import 'page.dart' as model;

/// グローバルのタブインデックス(0: ダッシュボード, 1: ページ一覧)
final tabIndex = Observable(0);

/// グローバルのメモストア(アプリ全体で共有される状態)
final memo = Memo();

void main() {
  runApp(const MyApp());
}

/// アプリケーションのルートウィジェット
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Memo',
      home: const _HomeScreen(),
    );
  }
}

/// ホーム画面(ダッシュボードとページ一覧を切り替え可能)
class _HomeScreen extends StatelessWidget {
  const _HomeScreen();

  @override
  Widget build(BuildContext _) {
    return ReactionBuilder(
      builder: (context) {
        // メモの更新を監視して、更新時にSnackBar を表示
        return reaction((_) => memo.lastUpdatedAt, (_) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('メモが更新されました')),
          );
        });
      },
      // Observer でラップすることで、tabIndex の変更を監視して自動的に再描画
      child: Observer(
        builder: (context) {
          final index = tabIndex.value;

          return Scaffold(
            appBar: AppBar(title: const Text('Flutter Memo')),
            // IndexedStack で画面を切り替え(両方の状態を保持)
            body: IndexedStack(
              index: index,
              children: const [_DashBoard(), _PageList()],
            ),
            bottomNavigationBar: BottomNavigationBar(
              currentIndex: index,
              onTap: (index) {
                // runInAction で Observable の値を更新
                runInAction(() => tabIndex.value = index);
              },
              items: [
                BottomNavigationBarItem(
                  icon: Icon(Icons.dashboard),
                  label: 'Dashboard',
                ),
                BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Pages'),
              ],
            ),
            // ページ一覧画面のときのみ、新規作成ボタンを表示
            floatingActionButton: index == 0
                ? null
                : FloatingActionButton(
                    onPressed: () {
                      final page = model.Page();
                      _Editor.show(context, page);
                    },
                    child: const Icon(Icons.add),
                  ),
          );
        },
      ),
    );
  }
}

/// ダッシュボード画面
class _DashBoard extends StatelessWidget {
  const _DashBoard();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: .start,
      children: [
        // Observer で memo の computed プロパティを監視
        Observer(
          builder: (_) {
            // computed プロパティから値を取得
            final numPages = memo.pageCount;
            final lastModified = memo.lastUpdatedAt;

            return Row(
              mainAxisAlignment: .spaceAround,
              children: [
                Text('ページ数: $numPages'),
                Text(
                  '最新: ${lastModified != null ? lastModified.toLocal().toString() : "なし"}',
                ),
              ],
            );
          },
        ),
      ],
    );
  }
}

/// ページ一覧画面
class _PageList extends StatelessWidget {
  const _PageList();

  @override
  Widget build(BuildContext context) {

    // memo.pages の変更を監視
    return Observer(
      builder: (_) {
        final pages = memo.pages;

        return ListView.builder(
          itemCount: pages.length,
          itemBuilder: (context, index) {
            final page = pages[index];
            // 各 ListTile を Observer でラップして、個別のページの変更を監視
            return Observer(
              builder: (_) => ListTile(
                title: Text(page.title),
                onTap: () {
                  _Editor.show(context, page.clone());
                },
                trailing: IconButton(
                  icon: Icon(Icons.delete),
                  onPressed: () {
                    // アクションを実行してページを削除
                    memo.removePage(page);
                  },
                ),
              ),
            );
          },
        );
      },
    );
  }
}

/// ページエディタ画面(モーダルボトムシートで表示)
class _Editor extends StatelessWidget {
  const _Editor({required this.page});

  /// 編集対象のページ
  final model.Page page;

  /// エディタ画面を表示する
  static void show(BuildContext context, model.Page page) {
    showModalBottomSheet(
      context: context,
      builder: (_) {
        return _Editor(page: page);
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: .all(16),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(8),
          topRight: Radius.circular(8),
        ),
      ),
      child: Form(
        child: Column(
          children: [
            /// タイトル入力欄
            TextFormField(
              initialValue: page.title,
              decoration: InputDecoration(labelText: 'タイトル'),
              onChanged: (value) {
                // アクションを実行してタイトルを更新
                page.setTitle(value);
              },
            ),
            const SizedBox(height: 16),

            /// 内容入力欄
            Expanded(
              child: TextFormField(
                initialValue: page.content,
                decoration: InputDecoration(labelText: '内容'),
                expands: true,
                minLines: null,
                maxLines: null,
                onChanged: (value) {
                  // アクションを実行して内容を更新
                  page.setContent(value);
                },
              ),
            ),
            ElevatedButton(
              onPressed: () {
                // ページを保存して画面を閉じる
                memo.addPage(page);
                Navigator.of(context).pop();
              },
              child: Text(page.isNew ? '登録' : '更新'),
            ),
          ],
        ),
      ),
    );
  }
}
page.dart
import 'package:mobx/mobx.dart';
import 'package:uuid/uuid.dart';

part 'page.g.dart';

/// メモ1件分
class Page = PageBase with _$Page;

abstract class PageBase with Store {

  /// ページID
  String? uuid;

  /// 新規ページかどうか
  bool get isNew => uuid == null;

  /// ページタイトル
  @observable
  String title = '';

  /// ページ内容
  @observable
  String content = '';

  /// 最終更新日時
  @observable
  DateTime updatedAt = DateTime.now();

  /// ページを複製する
  Page clone() {
    return Page()
      ..uuid = uuid
      ..title = title
      ..content = content
      ..updatedAt = updatedAt;
  }

  /// ページを保存する
  @action
  void save() {
    uuid ??= const Uuid().v4();
    updatedAt = DateTime.now();
  }

  /// タイトルを設定する
  @action
  void setTitle(String title) {
    this.title = title;
    updatedAt = DateTime.now();
  }

  /// 内容を設定する
  @action
  void setContent(String content) {
    this.content = content;
    updatedAt = DateTime.now();
  }
}
memo.dart
import 'package:mobx/mobx.dart';
import 'page.dart';

part 'memo.g.dart';


/// メモ全件
class Memo = MemoBase with _$Memo;

abstract class MemoBase with Store {

  /// ページリスト
  @observable
  var pages = ObservableList<Page>();

  /// 最終更新日時
  @computed
  DateTime? get lastUpdatedAt {
    if (pages.isEmpty) {
      return null;
    }
    return pages.map((page) => page.updatedAt).reduce((a, b) => a.isAfter(b) ? a : b);
  }

  /// ページ数
  @computed
  int get pageCount => pages.length;

  /// ページを追加する
  @action
  void addPage(Page page) {
    if(page.uuid == null) {
      page.save();
      pages.add(page);
      return;
    }

    final index = pages.indexWhere((p) => p.uuid == page.uuid);
    if (index >= 0) {
      pages[index] = page;
    }
  }

  /// ページを削除する
  @action
  void removePage(Page page) {
    final index = pages.indexWhere((p) => p.uuid == page.uuid);
    if (index < 0) {
      return;
    }
    pages.removeAt(index);
  }
}

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?