all_observer 1.2.0
all_observer: ^1.2.0 copied to clipboard
Lightweight reactive state management for Flutter with zero external dependencies: observable values plus an auto-tracking Observer widget.
all_observer #
π§π· Leia em PortuguΓͺs
Reactive state for Flutter, zero dependencies. Observable values plus an
auto-tracking Observer widget β a small, safe, dependency-free core for
apps that want reactivity without a full state-management framework.
Why all_observer #
- Zero dependencies. The whole reactive core β tracking, notification,
collections, workers β is built on
Dart/Flutteralone. NoStream, no code generation, no external package to keep in sync with your Flutter version. - No boilerplate. No providers to register, no context to wire up, no
base classes to extend.
final count = 0.obs;plusObserver(() => ...)is a complete, working reactive pair. - Granular by construction. Dependencies are discovered by reading
.valueduring a build, not declared up front, so anObserveronly rebuilds for what it actually reads β including conditional branches (if (a) read x else read y), which are re-evaluated correctly on every build. - Safe by default. Rebuilds guard against unmounted widgets, a
synchronous update cycle (A β B β A) stops instead of overflowing the
stack, an exception inside one listener never stops the others, and
every misuse case (empty
Observer, write during build, write afterclose()) warns instead of crashing your app β with an opt-instrictModethat turns those same warnings into hard failures for CI. - Interoperable, not a walled garden.
Observable<T>is aValueListenable<T>, so it drops straight intoValueListenableBuilder,AnimatedBuilder,Listenable.merge, or any existing Flutter API that already understands that interface.
When to reach for it #
Small to medium apps and features that want local/global reactive state β counters, form fields, loading flags, a reactive list/cache, a computed summary β without adopting a full architecture (BLoC-style event/state plumbing, code-generated providers, etc.). It also works well alongside a larger architecture, as the reactive primitive under a view-model or controller class.
When something else may fit better #
If you need dependency injection, route-based state scoping, or
compile-time-checked provider graphs, a dedicated DI/state framework will
give you more structure than this package intentionally provides.
all_observer has no opinion on where your state lives β only on how it
notifies β so it composes with those frameworks rather than replacing
them (e.g. wrap an Observable inside a provider/service you already
manage).
Quick start (30 seconds) #
import 'package:all_observer/all_observer.dart';
final count = 0.obs; // ObservableInt
Observer(() => Text('${count.value}'));
count.value++; // rebuilds the Text above, and only that widget
Create observables from any type with .obs: 0.obs, 'hi'.obs,
false.obs, 9.99.obs, <String>[].obs, or wrap a custom type with
Observable<User?>(null, name: 'user'). Read .value inside an Observer
builder and the widget rebuilds automatically whenever it changes β
dependencies are re-discovered on every build, so conditional reads work
out of the box.
ValueListenable interop #
Every Observable<T> implements ValueListenable<T>, so it plugs directly
into anything that already speaks that interface β no adapter needed:
ValueListenableBuilder<int>(
valueListenable: count, // an Observable<int> works here directly
builder: (context, value, _) => Text('$value'),
);
AnimatedBuilder(animation: Listenable.merge([count, otherObservable]), ...);
Derived values with Computed #
final firstName = 'Carlos'.obs;
final lastName = 'Castro'.obs;
final fullName = Computed(() => '${firstName.value} ${lastName.value}');
Observer(() => Text(fullName.value)); // recomputes only when needed
Computed<T> is lazy (never runs before the first read), memoized
(cached until a dependency notifies), reuses the same tracking mechanism
as Observer (so conditional/dynamic dependencies work the same way),
and only notifies its own listeners when the recomputed value actually
differs from the previous one. Call close() to unsubscribe from all
current dependencies.
For a narrower derived value from a single Observable, select is sugar
over the same pattern: user.select((u) => u.name) is exactly
Computed(() => user.value.name). The caller owns and must close() the
returned Computed.
Custom equals on Computed #
final fahrenheit = Computed<double>(
() => celsius.value * 9 / 5 + 32,
equals: (a, b) => (a - b).abs() < 0.01,
);
Like Observable, Computed accepts an equals override to decide
whether a recomputed value actually changed and should notify β useful for
floating-point tolerances or partial-field comparisons.
Diamond dependency graphs and batch #
A "diamond" is two Computeds both derived from the same source, with a
third depending on both. Wrap the writes that feed such a graph in
Observable.batch(): the recompute of any Computed in the graph is
deferred until every write in the batch has settled, so it recomputes at
most once, always from fully consistent upstream values. See "Known
limitations" below for what happens without batch.
Coalescing writes with Observable.batch #
Observable.batch(() {
firstName.value = 'Carlos';
lastName.value = 'Castro';
age.value = 30;
}); // manual listen()/ever() listeners fire exactly once, at the end
Writes still apply immediately and consistently inside the callback β
only the notification to manual subscribers (listen, ever, etc.) is
deferred and deduplicated. An Observer widget already coalesces
multiple dependency changes into a single rebuild per frame on its own,
so batch() mainly matters for manual subscriptions. Nested batch()
calls are supported; if the callback throws, the pending notifications
built up so far are discarded and the exception propagates normally.
Custom equality with equals #
final price = Observable<double>(
9.99,
equals: (a, b) => (a - b).abs() < 0.01,
);
By default, a write only notifies when the new value differs from the
current one via ==. Pass equals to use a different comparison β for
example, a tolerance for floating-point values, or comparing only part of
a larger object.
Reactive collections #
final items = <String>[].obs; // ObservableList<String>
Observer(() => Text('${items.length} items'));
items.add('one'); // notifies once
items.addAll(['two', 'three']); // still notifies once, not three times
items.removeWhere((e) => e == 'two'); // once, and only if something matched
ObservableList/ObservableMap/ObservableSet behave like their
built-in counterparts (ListBase/MapBase/SetBase) for every read β
length, [], contains, iteration β while every mutating member
notifies at most once per call, never once per element. A no-op
mutation (adding a Set element that's already there, removeWhere that
matches nothing, assigning an identical value to an existing map key)
notifies zero times.
Async values with ObservableFuture #
final userFuture = ObservableFuture<User>(() => api.fetchUser(id));
Observer(() => userFuture.value.when(
loading: (previousData) => const CircularProgressIndicator(),
data: (user) => Text(user.name),
error: (error, stackTrace) => Text('Error: $error'),
));
userFuture.refresh(); // re-runs the future, e.g. for pull-to-refresh
ObservableFuture<T> is an Observable<AsyncState<T>> that runs a
Future<T> Function() and tracks its loading/data/error lifecycle
automatically (autoStart: true by default; pass false and call run()
manually otherwise). AsyncLoading.previousData carries the last known
value while a refresh() is in flight, for stale-while-loading UIs. Every
run()/refresh() call is race-safe: if a newer call starts before an
older one resolves, the older result (success or error) is discarded when
it eventually arrives, and any in-flight result is also discarded if
close() was called meanwhile.
Fine-grained rebuilds with Observer.withChild #
Observer.withChild(
builder: (context, child) => Row(
children: [Text('${count.value}'), child],
),
child: const ExpensiveStaticWidget(),
);
A static child subtree, a common technique for avoiding rebuilds of
expensive widgets that don't depend on any observable: child is built
once and passed back into builder on every rebuild instead of being
reconstructed.
setValue, an unambiguous way to assign null #
final name = Observable<String?>('Carlos');
name.setValue(null); // assigns null and notifies
call() treats a null argument as "no argument" (to support the
no-argument observable() read form), so observable(null) reads instead
of assigning. setValue(newValue) is equivalent to value = newValue and
assigns null unambiguously; it is also handy as a tear-off (e.g. directly
as an onChanged callback).
Local, self-contained state with ObserverValue #
ObserverValue<ObservableInt>(
(data) => ElevatedButton(
onPressed: () => data.value++,
child: Text('${data.value}'),
),
0.obs,
);
A thin convenience over Observer for state that's created and consumed
right where it's used: pass the observable in, get it back inside
builder on every rebuild β no separate variable to declare above the
widget, no explicit Observer(() => ...) wrapping.
Side effects with workers #
final query = ''.obs;
final isLoggedIn = false.obs;
final count = 0.obs;
final scrollOffset = 0.0.obs;
// Runs 400ms after the last change β perfect for search-as-you-type.
final debounceWorker = debounce(query, (String value) {
runSearch(value);
}, time: const Duration(milliseconds: 400));
// Runs once, then disposes itself automatically.
once(isLoggedIn, (bool value) {
if (value) analytics.logLogin();
});
// Runs on every change, like a manual listener with a friendlier name.
final everWorker = ever(count, (int value) => print('count is now $value'));
// Runs at most once per `time`, immediately on the first change.
final intervalWorker = interval(scrollOffset, (double value) {
saveScrollPosition(value);
}, time: const Duration(seconds: 1));
// Dispose whichever ones you keep a reference to when you're done:
Workers([debounceWorker, everWorker, intervalWorker]).dispose();
Workers are the recommended way to run non-widget side effects (network
calls, analytics, persistence) off an observable change, instead of
sprinkling addListener calls by hand. once disposes itself after
firing; debounce/interval cancel their internal Timer on dispose()
so nothing fires after you're done with them.
Colored debug logs #
Enable ObserverConfig.logging = true during development to see reactivity
happen in your terminal, color-coded by event type:
| Event | Color |
|---|---|
| β creation | green |
| β» value update | cyan (values in magenta) |
| π Observer tracking | blue |
| β dispose | gray |
| β misuse warning | bold yellow |
[all_observer] β Observable<int>(count) criado β 0
[all_observer] β» Observable<int>(count): 0 β 1
[all_observer] π Observer(contador) rastreando: [count, isLoading]
[all_observer] β Observable<int>(count) descartado (2 listeners removidos)
Set ObserverConfig.useColors = false on terminals without ANSI support.
Misuse warnings (an Observer that reads nothing, a write after close(),
a write during build, a probable listener leak) are on by default via
ObserverConfig.warnings and never crash the app β set strictMode = true
to turn the "empty Observer" case into an exception for CI/tests.
Design decisions #
Rebuilds are guarded against already-unmounted widgets: the internal
callback checks mounted before scheduling work, and defers to the next
frame instead of a bare microtask when a change happens mid-build. Nested
reactive builders are supported correctly through a stack-based dependency
tracker, rather than a single mutable "current context" that nested
tracking could clobber. Notification semantics are a single, predictable
rule β a write only notifies if the new value differs from the current one
β with no special-casing for first assignment; mutable objects changed in
place can force a notification via refresh(). Equality (==/hashCode)
is never overridden on the reactive wrapper, so comparisons always mean
what they say: compare .value explicitly. The core has no Stream or
StreamController inside it β listen() is built directly on top of a
lightweight listener registry, keeping the reactive core small. And rather
than throwing on likely mistakes, the package favors friendly, non-fatal
warnings by default, with an opt-in strict mode for teams that want hard
failures in CI.
More #
ObservableList,ObservableMap,ObservableSet: reactive collections; reading any member tracks it, mutating any member notifies exactly once per call (bulk operations likeaddAll/removeWhere/retainWherenever notify per element).Computed<T>: lazy, memoized derived values built on the same dependency tracker asObserver, with an optional customequalsand batch-aware diamond-glitch mitigation.ObservableFuture<T>/AsyncState<T>: race-safe async loading/data/error state built onObservable.Observable.batch: coalesces multiple writes into one notification per changed observable, for manual subscribers.Observer.withChild: rebuilds only the builder-owned part of a subtree, reusing a staticchildacross rebuilds.Observable.select: sugar for a narrowerComputedderived from oneObservable.ObserverValue<T>: local, self-contained reactive state without managing an observable's lifecycle separately.ever,once,debounce,interval: workers for side effects driven by observable changes.- A synchronous update cycle (A's listener writes B, B's listener writes A, ...) is stopped after a bounded notification depth with a descriptive error, instead of a raw stack overflow; an exception thrown inside one listener never stops the other listeners of the same observable from running.
- See
/examplefor a runnable demo (counter, reactive list, worker, debug-log toggle), and/benchmarkfor manual Stopwatch-based microbenchmarks.
Migrating from other reactive state solutions #
all_observer covers the same core concepts most reactive state
approaches do, under its own names. This is a concept-by-concept map, not
a name-for-name port of any specific library:
| Concept | all_observer |
|---|---|
| Rx-style reactive value | Observable<T> (.obs to create one) |
| Reactive builder widget, auto-tracking dependencies | Observer |
| Derived/computed reactive value | Computed<T> |
Side-effect helpers on value change (ever/once/debounce/interval) |
Same names: ever, once, debounce, interval |
| Async loading/data/error state | ObservableFuture<T> / AsyncState<T> |
| Coalesced/transactional writes | Observable.batch(() { ... }) |
| Dispose / teardown | close() on every Observable/Computed/collection |
What has no equivalent here, by design β all_observer only handles
reactivity, not app architecture:
- Routing / navigation: use Flutter's own
Navigator/Router, or a dedicated routing package. - Snackbars / dialogs / overlays: use Flutter's own
ScaffoldMessenger,showDialog,showModalBottomSheet, etc. directly βall_observerhas no UI-side-effect layer to hook into these. - Dependency injection / service location: bring your own DI solution
(a simple constructor-passed singleton, an
InheritedWidget, or a dedicated DI package) and storeObservables inside the services/ controllers it manages βall_observerhas no opinion on where state lives, only on how it notifies.
Known limitations #
Observable.batch()is a performance optimization, not a consistency requirement. Since v1.2.0 every write β even a standaloneobservable.value = xoutside any explicitbatch()β is automatically routed through the same two-phase flush thatbatch()uses. Diamond dependency graphs (ComputedA and B both derived from source S,ComputedC depending on A and B) always recompute exactly once, always from fully settled upstream values β no glitch, no batch required. Wrapping multiple writes inbatch()remains useful to coalesce notifications: all writes in the callback are committed first, then listeners are notified once per changed observable, instead of once per write.Computedstays subscribed after its first read, untilclose(). Reading.value(or attaching a listener) makes aComputedsubscribe to its current dependencies indefinitely β it does not unsubscribe itself just because nobody is listening anymore. Callclose()once you're done with aComputedyou created manually (short-lived ones, e.g. fromselect, are easy to forget).- Single-isolate confinement. Like the rest of Dart, every
Observable/Computed/collection is confined to the isolate that created it; there is no cross-isolate synchronization. UseSendPort/ReceivePortorcomputeto move data between isolates and write back to the observable on its own isolate.
Other packages by us #
all_observer is part of a small family of zero/low-dependency Dart &
Flutter packages published under the
opensource.tatamemaster.com.br
verified publisher:
all_validations_brβ Brazilian document validation (CPF, CNPJ, CNH, PIX), 23 input formatters/masks, and pure-Dart utilities (JWT, UUID, currency, ChaCha20-Poly1305/AES encryption).all_boxβ synchronous, lightweight key-value storage for Flutter with crash-safe writes (write-ahead + atomic rename) and a pure-Flutter reactive layer.all_image_compressβ pure-Dart image compression (JPEG, PNG, GIF, BMP, TIFF, WebP), running in isolates to avoid UI jank, no native code required.
π₯ Contributors #
Made with contrib.rocks.
Contributions are welcome! Read CONTRIBUTING.md to get started.
Issues and pull requests are welcome at the GitHub repository.