mobx.dart icon indicating copy to clipboard operation
mobx.dart copied to clipboard

ObservableList / ObservableMap not rebuild ui after mutate property

Open Poloten opened this issue 1 year ago • 5 comments

ChatItem is class without the Store , I attempt to mutate propery in ObservableList and ObservableMap but tracking changes not working. I tried with chatStore.chats.toList() and without toList(), and add observer inside itemBuilder, but nothing happend. UI updating only if i change the route or reassing whole chatsMap / chats. Please can u help me with this simple task.

I also extend ChatItem class with equatable package to override hashCode and operator == I use .g generator

mobx: ^2.4.0 flutter_mobx: ^2.2.1+1 mobx_codegen: ^2.6.2

class ChatsStore = _ChatsStore with _$ChatsStore;
abstract class _ChatsStore with Store {

@MakeObservable(useDeepEquality: true)
ObservableList<ChatItem> chats = ObservableList.of([]);
@MakeObservable(useDeepEquality: true)
ObservableMap<String, ChatItem> chatsMap = ObservableMap<String, ChatItem>.of({});
@computed
ObservableList<ChatItem> get computedChatList => ObservableList.of(chatsMap.value);

@action
Future<void> getChats() async {
  List<ChatItem>? chatList = await getChatsList();
  if (chatList == null) return;

  // try List
  chats.clear();
  chats.addAll(chatList);

   // try Map
  chatsMap.clear();
  for (var chat in chatList) {
    chatsMap[chat.id] = chat;
  }
}

/// I tried to update property inside **ChatItem**
@action
updateUnread(String id) {
  print('hash before: ${ chatsMap[id].hashCode}');
  chatsMap[id].unreadCount = 99;
  print('hash after: ${ chatsMap[id].hashCode}'); // hash changed
  
  var found = chats.firstWhere((el) => el.id == id);
  found.unreadCount = 98;
}
}

Widget

Column(children: [
        Expanded(
          child: Observer(builder: (_) {
            var chatList = chatStore.chats.toList();
            print('REBUILD');
            return RefreshIndicator(
                onRefresh: handleRefresh,
                color: Colors.white,
                backgroundColor: Colors.blue,
                child: ListView.separated(
                  separatorBuilder: (_, int index) => const DididerWmKeyVal(),
                  itemCount: chatList.length,
                  itemBuilder: (_, int index) {
                    var element = chatList[index];
                    return InkWell(
                      child: SizedBox(
                        height: 70,
                        child: Row(
                          children: [
                            Container(
                              padding: EdgeInsets.only(top: 10),
                              alignment: Alignment.topCenter,
                              width: 60,
                              child: Stack(
                                children: [
                                  AvatarWidget(wmid: element.id),
                                  BadgeWidget(count: element.unreadCount)
                                ],
                              ),
                            ),
                            Expanded(
                              child: Padding(
                                padding: EdgeInsets.only(top: 4),
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    Text(element.name),
                                    Text('${element.hint}'.trim()),
                                  ],
                                ),
                              ),
                            ),
                            ListItemTime(time: element.time),
                          ],
                        ),
                      ),
                      onTap: () {
                        chatStore.updateUnread("78000000");
                  },
                ));
          }),
        ),
      ]),

Poloten avatar Nov 12 '24 14:11 Poloten

I've been having the same issue while using ObservableList on the versions below:

mobx: 2.5.0 flutter_mobx: 2.3.0 mobx_codegen: 2.7.0

CaLouro avatar Apr 28 '25 20:04 CaLouro

@Poloten @CaLouro The issue you're experiencing is that MobX doesn't automatically track changes to properties inside objects within ObservableList or ObservableMap. When you modify ChatItem properties, MobX doesn't detect these deep changes because ChatItem itself isn't observable.

https://mobx.netlify.app/guides/when-does-mobx-react

Solution 1: Make ChatItem Observable

Convert your ChatItem class to a MobX store:

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

part 'chat_item.g.dart';

class ChatItem = _ChatItem with _$ChatItem;

abstract class _ChatItem with Store implements EquatableMixin {
  _ChatItem({
    required this.id,
    required this.name,
    required this.hint,
    required this.time,
    this.unreadCount = 0,
  });

  final String id;
  final String name;
  final String hint;
  final String time;

  @observable
  int unreadCount;

  // Equatable implementation
  @override
  List<Object?> get props => [id, name, hint, time, unreadCount];

  @override
  bool get stringify => true;
}

Solution 2: Make ChatItem Immutable

Immutable ChatItem with Replacement Strategy

Benefits of Immutable Approach:

  • Predictable State: No accidental mutations
  • Better Testing: Easier to test and reason about
  • MobX Compatibility: MobX detects changes when you replace objects
  • Debugging: Easier to track state changes
import 'package:equatable/equatable.dart';

class ChatItem extends Equatable {
  const ChatItem({
    required this.id,
    required this.name,
    required this.hint,
    required this.time,
    this.unreadCount = 0,
  });

  final String id;
  final String name;
  final String hint;
  final String time;
  final int unreadCount;

  // copyWith method for creating modified copies
  ChatItem copyWith({
    String? id,
    String? name,
    String? hint,
    String? time,
    int? unreadCount,
  }) {
    return ChatItem(
      id: id ?? this.id,
      name: name ?? this.name,
      hint: hint ?? this.hint,
      time: time ?? this.time,
      unreadCount: unreadCount ?? this.unreadCount,
    );
  }

  @override
  List<Object?> get props => [id, name, hint, time, unreadCount];

  @override
  bool get stringify => true;
}
import 'package:mobx/mobx.dart';

part 'chats_store.g.dart';

class ChatsStore = _ChatsStore with _$ChatsStore;

abstract class _ChatsStore with Store {
  @observable
  ObservableList<ChatItem> chats = ObservableList.of([]);

  @observable
  ObservableMap<String, ChatItem> chatsMap = ObservableMap<String, ChatItem>.of({});

  @computed
  ObservableList<ChatItem> get computedChatList => 
      ObservableList.of(chatsMap.values);

  @action
  Future<void> getChats() async {
    List<ChatItem>? chatList = await getChatsList();
    if (chatList == null) return;

    chats.clear();
    chats.addAll(chatList);

    chatsMap.clear();
    for (var chat in chatList) {
      chatsMap[chat.id] = chat;
    }
  }

  @action
  void updateUnread(String id, int newCount) {
    // Update in map
    final currentChat = chatsMap[id];
    if (currentChat != null) {
      final updatedChat = currentChat.copyWith(unreadCount: newCount);
      chatsMap[id] = updatedChat;
    }

    // Update in list
    final index = chats.indexWhere((chat) => chat.id == id);
    if (index != -1) {
      final updatedChat = chats[index].copyWith(unreadCount: newCount);
      chats[index] = updatedChat;
    }
  }
}

amondnet avatar May 23 '25 05:05 amondnet

The reason MobX behaves this way is due to its reactivity model and how it tracks observability. Here's why:

1. Shallow Observation by Default

MobX observes references to objects in collections, not the internal properties of those objects. When you have:

@observable
ObservableList<ChatItem> chats = ObservableList.of([]);

MobX tracks:

  • ✅ Adding/removing items from the list
  • ✅ Replacing items in the list
  • ❌ Changes to properties inside the ChatItem objects

2. Reference-Based Change Detection

MobX uses reference equality to detect changes. When you mutate a property inside an object:

chatsMap[id].unreadCount = 99; // Same object reference

The object reference stays the same, so MobX doesn't detect any change. But when you replace the entire object:

chatsMap[id] = newChatItem; // Different object reference

MobX detects this as a change because the reference changed.

3. Performance Optimization

This design is intentional for performance reasons:

  • Deep observation would require MobX to recursively watch every property of every object
  • This would create many more observers and significantly impact performance
  • It would also create complex dependency graphs

4. Explicit vs Implicit Reactivity

MobX follows the principle of explicit reactivity:

  • You must explicitly mark what should be observable
  • If you want object properties to be reactive, you need to make the object itself observable
  • This prevents accidental performance issues from over-observation

5. Memory and Garbage Collection

Deep observation would create strong references to all nested objects, potentially preventing garbage collection and causing memory leaks.

6. Architectural Philosophy

This behavior encourages better architectural patterns:

  • Immutable data structures (which you're now using)
  • Single source of truth for state
  • Predictable state changes through actions

The Two Solutions:

  1. Make objects observable: ChatItem becomes a MobX store
  2. Use immutable objects: Replace entire objects when changes occur

Both solutions work because they align with MobX's reference-based change detection system. The immutable approach is often preferred because it's more predictable and prevents accidental mutations.

amondnet avatar May 23 '25 06:05 amondnet

@amondnet thanks for the very informational reply.

I will double-check my code to see what exactly I was doing. But I as far as I remember I was not changing only deep objects properties and was in fact changing the list itself. But then again, I will double-check and come back with that info.

Is the ObservableList behaving normally everywhere else for you?

CaLouro avatar May 24 '25 00:05 CaLouro

@Poloten @CaLouro You'll also need to modify the Observer-wrapped Widgets. I haven't run the code, but it looks like the current code only rebuilds the entire ListView when the entire list changes(var chatList = chatStore.chats.toList()), or when the length of the list changes(chatStore.chats.length).

ListView.builder(
  itemCount: chatStore.chats.length,
  itemBuilder: (context, index) {
    return Observer(  // Wrap each item with Observer
      builder: (_) {
        final chatItem = chatStore.chats[index];
        return ListTile(
          title: Text(chatItem.title),
          subtitle: Text(chatItem.content),
          // ... other properties
        );
      },
    );
  },
)

https://mobx.netlify.app/api/observers#observer-widget

Immediate context for builder

The key thing to note is that the builder function will only track the observables in its immediate execution context. If an observable is being used in a nested function, it will not be tracked. Make sure to dereference (a.k.a. read) an observable in the immediate execution context. If no observables are found when running the builder function, it will warn you on the console.

This is one of the most common gotchas when using MobX. Just because you have nested functions inside a builder, which are reading an observable, it does not actually track the observable. It can appear deceiving but the fact is, the observable was not in the immediate execution context. Be watchful for this scenario, especially when your Observer-wrapped Widgets are not updating properly.

https://mobx.netlify.app/api/observable#observablelist

https://github.com/mobxjs/mobx.dart/issues/402#issuecomment-576685334 https://github.com/mobxjs/mobx.dart/blob/a550bbf25567abdbfc6b57b9f9a92565e86933f6/mobx_examples/lib/todos/todo_widgets.dart#L56 https://github.com/mobxjs/mobx.dart/issues/527

amondnet avatar May 27 '25 02:05 amondnet