Search code examples
flutterdartmobx

How can I observe a list inside an observable when using MobX in Flutter?


I have a simplified situation that I can't make to work in Flutter: a MobX observable object (Book) has a list of chapters that I also want to observe so my UI is redrawn to show a new chapter just created.

In reality I have more complex models, this is just the simpliest case I managed to show my problem.

When I create a new chapter, the new chapter isn't presented in the list but if I go back to the main page and back it appears in the chapter list.

I can't change the chapters property inside Book to an ObservableList for other reasons in my app: final ObservableList<Chapter> chapters; is a no-no.

I looked several similar questions in stackoverflow but couldn't find a solution to my problem on any of them.

How can I observe the chapters in my current book so my UI properly updates after creating a new chapter?

My model:

class Book {
  final String title;
  final List<Chapter> chapters;

  Book(this.title, this.chapters);
}

class Chapter {
  final String title;

  Chapter(this.title);
}

My MobX store:

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

part 'book_editor_store.g.dart';

class BookEditorStore = BookEditorStoreBase with _$BookEditorStore;

abstract class BookEditorStoreBase with Store {
  @readonly
  Book _currentBook = Book('', []);

  @readonly
  Chapter? _currentChapter;

  @computed
  int get chapterCount => _currentBook?.chapters.length ?? 0;

  @computed
  List<Chapter> get chapters => _currentBook?.chapters ?? [];

  @action
  setBook(Book book) {
    _currentBook = book;
  }

  @action
  addChapter(Chapter chapter) {
    _currentBook?.chapters.add(chapter);
  }
}

My main.dart:

import 'book_editor_page.dart';
import 'book_editor_store.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return Provider(
      create: (context) => BookEditorStore(),
      child: MaterialApp(
        title: 'MobX reactions',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        home: const MainPage(),
      ),
    );
  }
}

class MainPage extends StatelessWidget {
  const MainPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Main Page'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => const BookEditorPage()),
            );
          },
          child: const Text('Editor'),
        ),
      ),
    );
  }
}

My BookEditorPage:

import 'book_editor_store.dart';
import 'book.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class BookEditorPage extends StatelessWidget {
  const BookEditorPage({super.key});

  @override
  Widget build(BuildContext context) {
    final bookEditorStore = Provider.of<BookEditorStore>(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Book Editor'),
      ),
      body: Column(
        children: [
          Observer(
            builder: (_) => ListView.builder(
              shrinkWrap: true,
              itemCount: bookEditorStore.chapterCount,
              itemBuilder: (_, index) {
                final chapter = bookEditorStore.chapters[index];
                return ListTile(
                  title: Text(chapter.title),
                );
              },
            ),
          ),
          ElevatedButton(
            onPressed: () => addChapter(context, bookEditorStore),
            child: const Text('Add Chapter'),
          ),
        ],
      ),
    );
  }

  void addChapter(BuildContext context, BookEditorStore bookEditorStore) {
    final TextEditingController titleController = TextEditingController();

    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: const Text('New Chapter'),
          content: TextFormField(
            controller: titleController,
            decoration: const InputDecoration(hintText: 'Enter chapter title'),
            autofocus: true,
          ),
          actions: <Widget>[
            TextButton(
              child: const Text('Cancel'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
            TextButton(
              child: const Text('Create'),
              onPressed: () {
                final String title = titleController.text;
                if (title.isNotEmpty) {
                  bookEditorStore.addChapter(Chapter(title));
                  Navigator.of(context).pop();
                }
              },
            ),
          ],
        );
      },
    );
  }
}

Solution

  • It's not elegant at all but it works: creating a separate observable chapter list inside the store (and taking care to mirror any changes in the real Book.chapters list in the observable copy).

    Just change the BookEditorStore to:

    import 'book.dart';
    import 'package:mobx/mobx.dart';
    
    part 'book_editor_store.g.dart';
    
    class BookEditorStore = BookEditorStoreBase with _$BookEditorStore;
    
    abstract class BookEditorStoreBase with Store {
      @readonly
      Book _currentBook = Book('', []);
    
      @readonly
      Chapter? _currentChapter;
    
      @computed
      int get chapterCount => _chapters.length;
    
      @readonly
      ObservableList<Chapter> _chapters = ObservableList<Chapter>();
    
      @action
      setBook(Book book) {
        _currentBook = book;
        _chapters = ObservableList<Chapter>.of(book.chapters);
      }
    
      @action
      addChapter(Chapter chapter) {
        _currentBook.chapters.add(chapter);
        _chapters.add(chapter);
      }
    }
    

    The rest remains the same and it works!

    I won't accept my answer for now in hope someone presents a less klugdy solution.