flutterx_live_data

Observable data holders inspired by Android LiveData: lazy mediators, history, persistence on a Map<String, String>, observable collections and widget builders.

Installation

flutter pub add flutterx_live_data
import 'package:flutterx_live_data/flutterx_live_data.dart';

Concepts

Need Type
A value that changes over time MutableLiveData<T> (read-only view: LiveData<T>)
A constant that fits where an observable is expected ImmutableLiveData<T>
One-shot events (navigation, snackbars) LiveEvent<T>
A value derived from other data MediatorLiveData<T>, data.transform(...)
A list, map, set or queue that notifies LiveList, LiveMap, LiveSet, LiveQueue
Undo/redo HistoryMutableLiveData and the History* collections
A value that survives restarts StorageMutableLiveData and the Storage* collections
An object that notifies when its fields change SelfLiveData

A data without a value is not initialized: initialized is false, valueOrNull is null and value throws StateError. MutableLiveData<int>(value: 0) starts initialized; MutableLiveData<int>.late() does not. A new observer receives the current value at once, then every change; a value equal to the current one (equality, == by default) is not notified.

Widgets

final counter = MutableLiveData<int>(value: 0);

LiveDataBuilder<int>(
  data: counter,
  placeholder: const CircularProgressIndicator(),
  builder: (context, value) => Text('$value'),
)

MultipleLiveDataBuilder.with2<int, String>(
  x1: counter,
  x2: name,
  builder: (context, count, name) => Text('$name: $count'),
)

with2 to with7 are typed; MultipleLiveDataBuilder(data: [...], builder: (context, values) => ...) takes a list. Both rebuild once at the end of the next frame, however many changes happened. In a State, declare observers once and let the mixin align them:

class _PageState extends State<Page> with ObserverMixin, StateObserver<Page> {
  @override
  void registerObservers() {
    events.observe(this, (message) => showMessage(message));
    observe2(user, settings, (user, settings) => sync(user, settings), dispatcher: Dispatcher.postFrame);
  }
}

registerObservers runs again on every dependency or widget change: observers still declared are kept, new ones added, dropped ones disposed, and all on dispose. observe* outside registerObservers throws StateError. Outside widgets use observeForever, which returns a Disposable:

final subscription = counter.observeForever(print);
subscription.dispose();

ObservableData is a Listenable and LiveData a ValueListenable, so ListenableBuilder, ValueListenableBuilder and Listenable.merge work.

Mediators and transformations

final label = counter.transform((value) => value.isEven ? 'even' : 'odd');

final total = MediatorLiveData<int>()..addTransformSource2<int, int>(a, b, (a, b) => a + b);

final user = MediatorLiveData<User>()
  ..initialize(() => repository.load(), invalidate: refreshTrigger);

Transforms may return a Future; the last one started wins. A mediator follows its sources only while observed; reactive: true keeps it following them always. Use addSource for a custom callback and invalidateSource / invalidateSources to run callbacks again. MediatorMutableLiveData, MediatorLiveEvent and the Mediator* collections add the same behavior to the other types.

Events

final messages = LiveEvent<String>();
messages.send('saved');
messages.postSend(repository.save().then((_) => 'saved'));

An observer gets only events sent after it registered. Equal consecutive events are all delivered, and with a Dispatcher every event arrives, in order; pending futures hold back the events posted after them.

Collections

final todos = LiveList<String>();
todos.add('milk');
LiveDataBuilder<List<String>>(data: todos, builder: (context, list) => Text('${list.length}'))

Collections implement List, Map, Set and Queue, and notify with themselves only when a mutation changes something. apply((data) => ...) mutates the underlying collection and notifies when the callback returns true; setTo replaces the content. LiveSelectableData holds the item of a list picked by id.

History

final text = HistoryMutableLiveData<String>(value: '');
text.value = 'a';
text.historyUndo();
LiveDataBuilder<bool>(data: text.canUndo, builder: (context, can) => ...)

Values are recorded as strings (JSON by default; pass encode/decode for other types), consecutive equal ones once, up to capacity (20). Also historyRedo, historyReplace, historyClear, index.

Persistent data

Storage* data persist in any Map<String, String>, such as localStorage of flutterx_preferences:

final theme = StorageMutableLiveData<ThemeMode>(
  storage: StorageInterface.enumValue(
    storage: localStorage,
    key: 'theme',
    values: ThemeMode.values,
    defaultValue: ThemeMode.system,
  ),
);

StorageInterface has factories for String, int, double, bool, enums, JSON objects, lists and maps. A value equal to the default is not stored but removed; an unreadable stored value falls back to the default, reported with FlutterError.reportError. reset() restores the default. The same StorageInterface feeds StorageLiveList, StorageLiveMap, StorageLiveSet and StorageLiveQueue; without a stored value or a default the data is uninitialized and the collections start empty.

Dispatchers

observeForever and the observe* methods call synchronously unless given a Dispatcher: Dispatcher.microtask, Dispatcher.postFrame (after a frame, never synchronous) or Dispatcher.endOfFrame. Changes arriving before the call runs are coalesced into the latest value, except for events.

Behavior to know

  • Values: the last write wins. postValue with a pending Future is superseded by any later value = or postValue; a failed future is reported with FlutterError.reportError and leaves the value.
  • A closure registered twice with observeForever, or a source added twice with addSource, on the same data throws StateError; Disposable.dispose is idempotent. removeObserver asserts the observer exists.
  • addListener listeners are synchronous, get no value, and count as observers: they activate lazy mediators.
  • ValueListenableBuilder reads value before listening: a mediator that is not reactive has no value yet and throws. Use LiveDataBuilder or reactive: true.
  • ImmutableLiveData keeps no registration: observers get the value once, and the same closure may be registered again.
  • Exceptions thrown by observers are reported with FlutterError.reportError, not propagated to the writer.

Claude Code plugin

The repository ships a plugin with skills that teach Claude Code to integrate the library:

/plugin marketplace add https://gitlab.com/dade.sliep/flutterx_live_data
/plugin install flutterx-live-data@flutterx-live-data

Libraries

flutterx_live_data
Observable data holders inspired by Android LiveData, with lazy activation, mediators, history, storage, observable collections and widget builders.