
A pragmatic, fast, and ergonomic Flutter state toolkit that blends reactive state, event orchestration, persistence, and view/scope glue into a clean, testable, feature-based architecture.
Zero boilerplate for simple state — strong patterns for complex flows.
Table of Contents
- Features
- Why Mastro
- Installation
- Project Structure (Feature-based)
- App-lifetime boxes
- Overall Flow
- Quick Start (Counter with local box)
- Reactive State
- Persistence (Persistro → PersistroLightro → PersistroMastro)
- Boxes & Events
- Widget Building
- MastroHooks (back-blocking UX)
- MastroWidget (view glue & lifecycle)
- Providers placement with MaterialApp (important)
- Memory Leak Detection
- Public API Reference (Quick Links)
- FAQ
- Design Patterns & Recipes
- Examples
- Contributions
- License
Features
- Feature-based structure: each feature owns its presentation, logic (boxes & events), and optional states.
- Reactive state:
Lightro<T>
andMastro<T>
both support.value
,.modify(...)
,.late()
and builder helpers. - New:
.safe
accessor on state containers for late initialization ergonomics. - Computed values:
Mastro.computed(...)
handles derived values with implicit dependency tracking (replaces olddependsOn
andcompute
methods). - Events engine: rich execution modes, callbacks, and back-blocking UX — but you can also just call box methods.
- Friendly builders:
MastroZone
rebuilds immediately when safe, supports state and tag-based updates. - Persistence:
PersistroLightro
/PersistroMastro
built on top ofSharedPreferences
viaPersistro
. - Scopes:
MastroHooks
integrates back-blocking UX for long-running tasks. - Views:
MastroWidget<T>
pairs a screen with its box (local or scoped) and exposes lifecycle hooks includingonViewAttached
/onViewDetached
. - New:
TriggerableZone
for imperative rebuilds via controllers. - New:
LifecycleZone
for simple lifecycle hooks without a box. - New: Memory leak detection via
MastroMemoryLeaksChecker
in debug mode. - Enhanced logging: Configurable via
LogOptions
and globalshowMastroLogs
.
Breaking Changes from Previous Versions:
MastroView
renamed toMastroWidget
for alignment with Flutter conventions.MastroBuilder
andTagBuilder
consolidated intoMastroZone
(handles both state and tag reactivity).BoxProvider
/MultiBoxProvider
renamed toBoxScope
/MultiBoxScope
.MastroScope
renamed toMastroHooks
.Mastro.dependsOn(...)
removed; useMastro.computed(...)
with implicit dependency capture.compute()
method fully removed.RebuildBoundary
removed; useTriggerableZone
for similar functionality.onViewAttached
/onViewDetached
now takeViewMetaData
instead ofMastroView
.- Removed
autoCleanupWhenAllViewsDetached
andautoCleanupWhenUnmountedFromWidgetTree
options (handled internally viaAutowire
).
Why Mastro
Mastro is a Flutter state toolkit designed for clarity, scalability, and developer control, offering a structured approach to managing state, logic, and UI updates without hidden magic.
- State management: With
Lightro<T>
andMastro<T>
, you directly control state updates using.value
or.modify(...)
, ensuring predictable behavior. - UI updates:
MastroZone
automatically detects which states (dependencies) are used during its build phase and listens to them. This eliminates the need to manually declare dependencies, reducing boilerplate and minimizing unnecessary rebuilds. Additionally, its support for tag-based pings enables lightweight UI refreshes without extra state objects, providing flexibility for dynamic UIs. - Structured logic with boxes:
MastroBox
encapsulates business logic, keeping views thin and testable. You can call methods directly for simple actions or use the optional events engine for advanced orchestration, including concurrency modes (parallel
,sequential
,solo
) and back-blocking UX. - Robust persistence: Built-in persistence with
PersistroLightro
andPersistroMastro
leveragesSharedPreferences
for seamless data storage, with factories for common types and custom codecs for complex objects, ensuring data survives app restarts. - Enhanced developer experience: Features like
MastroMemoryLeaksChecker
for detecting resource leaks, configurable logging withLogOptions
, and automatic resource cleanup viaAutowire
zones make Mastro robust for production apps. - Scalable architecture: The feature-based structure (
common/
,features/
) organizes code logically, making it ideal for large projects. Scoped boxes (BoxScope
,MultiBoxScope
) ensure global state is accessible without cluttering individual screens.
Mastro balances simplicity for small projects with powerful patterns for complex apps, making it a versatile choice for Flutter developers who value control and maintainability.
Installation
dependencies:
mastro: ^<latest>
// If you use persistence, initialize it once before runApp:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Persistro.initialize(); // shared prefs
runApp(const MyApp());
}
Project Structure (Feature-based)
Keep each feature self-contained: UI, logic (boxes + events), and optional typed states. Shared bits live in common/
.
Recommended Layout (visual + consistent)
lib/
common/ # theme · router · DI · shared states
theme/
routing/
env/
states/
features/
auth/
presentation/ # widgets & screens
auth_view.dart
widgets/
auth_form.dart
logic/ # box + events
auth_box.dart
auth_events.dart # (optional)
states/ # sealed/union types (optional)
auth_states.dart
todos/
presentation/
todos_view.dart
widgets/
todo_tile.dart
logic/
todos_box.dart
todos_events.dart # (optional)
app.dart # root MaterialApp / scopes / providers
main.dart
Naming convention (logic):
*_box.dart
for boxes*_events.dart
for events*_view.dart
for views
App-lifetime boxes
If you want a box to live for the whole app session, provide it above your app widget (wrap MaterialApp
).
void main() {
runApp(
MultiBoxScope(
scopes: [
BoxScope.scoped(factory: (context) => SessionBox()), // lives as long as the app
],
child: const MaterialApp(home: RootWidget()),
),
);
}
Placing the scope outside the MaterialApp
ensures the box isn’t recreated when routes are replaced and keeps its state intact.
Overall Flow
- Choose where your box lives
- Scoped (Global) — provide it near the app root with
BoxScope
/MultiBoxScope
if multiple screens need the same instance. - Local — pass a factory to the
MastroWidget
super constructor if the box is screen-local.
- Render the view
- Create
class MyWidget extends MastroWidget<MyBox>
(generic is mandatory). - Inside
build(context, box)
, you get a typedMyBox
whether it’s local or resolved fromBoxScope
.
- Build the UI from reactive state
- Wrap your dynamic widgets with
MastroZone
to handle rebuilds efficiently.
- Perform actions
- Simplest: call box methods (no events needed).
- Richer orchestration: dispatch events (
box.execute(...)
) to get concurrency modes, loose callbacks, and optional back-blocking (executeBlockPop
).
- (Optional) Persist state
- Swap to
PersistroLightro
/PersistroMastro
when a value must survive app restarts.
- (Optional) Scope UX
- Wrap screens with
MastroHooks
to handle pop callbacks.
Quick Start (Counter with local box)
import 'package:flutter/material.dart';
import 'package:mastro/mastro.dart';
void main() => runApp(const CounterApp());
class CounterApp extends StatelessWidget {
const CounterApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: CounterWidget());
}
}
class CounterBox extends MastroBox {
final count = 0.lightro;
// Simple action (no event required)
void increment() => count.value++;
}
class CounterWidget extends MastroWidget<CounterBox> {
CounterWidget({super.key}) : super(box: () => CounterBox()); // local box factory
@override
Widget build(BuildContext context, CounterBox box) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: MastroZone(
builder: (context) => Text('Count: ${box.count.value}', style: const TextStyle(fontSize: 36)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: box.increment,
child: const Icon(Icons.add),
),
);
}
}
Flexibility: Keep things simple with box methods; use events only where you need their extra power.
Reactive State
Lightro vs Mastro (Comparison)
Capability | Lightro | Mastro | Example |
---|---|---|---|
Reactive .value |
✅ | ✅ | state.value = x |
In‑place modify |
✅ | ✅ | state.modify((s) => s.field = ...) |
Uninitialized start late() |
✅ | ✅ | final token = Lightro<String>.late(); |
Computed values | — | ✅ | sum.computed(() => a.value + b.value); |
Validation | — | ✅ | state.setValidator((v) => v >= 0); |
Observers | — | ✅ | state.observe('log', print); |
Heads‑up: the standalone compute()
and dependsOn()
methods have been removed . Use computed(...)
to derive values with implicit dependency tracking.
Lightro
final isEnabled = false.lightro;
MastroZone(
builder: (context) => Switch(
value: isEnabled.value,
onChanged: (value) => isEnabled.value = value,
),
);
Mastro
class Profile { String name; int age; Profile(this.name, this.age); }
final profile = Profile('Alice', 30).mastro;
// In-place updates; one notify at the end.
await profile.modify((s) {
s.value.name = 'Bob';
s.value.age++;
});
// Observe & validate
profile
..setValidator((p) => p.name.isNotEmpty && p.age >= 0)
..observe('log', (p) => debugPrint('Profile → ${p.name}(${p.age})'));
Mastro Functions (What/When/How)
-
computed(T Function() derive)
What: wire this state to other state(s) and compute derived values.
When: you want derived values that update automatically.
How: call with a compute function. Dependencies are captured implicitly. Clear by disposing the Mastro instance.
-
setValidator(bool Function(T) validator, {void Function(T invalid)? onValidationError})
What: gate assignments to
.value
.When: you must enforce invariants (non‑negative totals, non‑empty names, etc.).
How: on invalid assignment,
.value
is not updated;onValidationError
fires with the rejected value. -
observe(String key, void Function(T value) handler)
/removeObserver(String key)
What: subscribe to value changes for side effects (logging, analytics, imperatives).
When: you need reactions outside the widget tree.
How: keys are unique; calling
observe
again with the same key replaces the old handler.
.modify() vs .value (when to use which?)
- Use
.value =
for direct replacements of simple values. - Use
.modify(...)
for read‑modify‑write on complex values to bundle edits and emit a single notification.
// Direct replacement
total.value = 0;
// Batched mutations (single notify)
await cart.modify((m) {
m.value.items.add(newItem);
m.value.taxes = computeTaxes(m.value.items);
});
Validation & Error Handling
- Invalid assignments are rejected silently with an optional
onValidationError(invalid)
callback. - Wrap business rules in
setValidator
and keep assignment sites clean. - Throwing during
.modify(...)
bubbles as usual; no partial notification is emitted.
late() state
.late()
creates an uninitialized state that throws if you read.value
too early.- The
.safe
getter returnsnull
before initialization — ideal for first paints:
final token = Lightro<String>.late();
final name = Lightro<String>.late();
Text(token.safe ?? 'No token'); // ✅ no throw on first build
// name.value; // ❌ throws (uninitialized)
name.value = 'Alex'; // ✅ initialize
final label = token.when(
uninitialized: () => 'No token',
initialized: (value) => 'Token: $value',
);
AsyncState
Model async flows declaratively — then wrap it in a reactive container to listen in UI.
final userState = const AsyncState<User>.initial().lightro;
// or: final userState = const AsyncState<User>.initial().mastro;
Future<void> loadUser() async {
userState.value = const AsyncState.loading();
try {
userState.value = AsyncState.data(await repo.fetchUser());
} catch (e) {
userState.value = AsyncState.error('Failed: $e');
}
}
MastroZone(
builder: (context) => userState.value.when(
initial: (_) => const Text('Tap to load'),
loading: () => const CircularProgressIndicator(),
data: (u) => Text('Hello ${u.name}'),
error: (msg, _) => Text(msg ?? 'Error'),
),
);
Persistence (Persistro → PersistroLightro → PersistroMastro)
PersistroLightro
and PersistroMastro
behave like regular Lightro
/ Mastro
but add persistence ( persist/restore/clear
, optional autoSave
).
Persistro (low-level key/value)
Initialize once before use.
Static API (all return Future
):
initialize()
putString/Int/Double/Bool/StringList(key, value)
getString/Int/Double/Bool/StringList(key)
isInitialized
(getter)
PersistroLightro (reactive Lightro + persistence)
Factories (required/optional args and defaults):
boolean(String key, {bool initial = false, bool autoSave = true})
number(String key, {num initial = 0.0, bool autoSave = true})
string(String key, {String initial = '', bool autoSave = true})
list<T>(String key, {required List<T> initial, required T Function(Object json) fromJson, bool autoSave = true})
map<T>(String key, {required Map<String, T> initial, required T Function(Object json) fromJson, bool autoSave = true})
json<T>(String key, {required T initial, required T Function(Map<String, Object?> json) fromJson, required Map<String, Object?> Function(T value) toJson, bool autoSave = true})
Constructor (custom codec, persisted as String
):
PersistroLightro<T>({required String key, required T initial, required String Function(T) encoder, required T Function(String) decoder, bool autoSave = true})
Instance methods :
Future<void> persist()
/restore()
/clear()
PersistroMastro (reactive Mastro + persistence)
Factories (same shapes + defaults as Lightro variant):
boolean
/number
/string
/list
/map
/json
Constructor (custom codec):
PersistroMastro<T>({required String key, required T initial, required String Function(T) encoder, required T Function(String) decoder, bool autoSave = true})
Instance methods :
Future<void> persist()
/restore()
/clear()
- Plus all
Mastro
APIs:computed
,setValidator
,observe
, ...
Boxes & Events
Local vs Scoped (Global) Boxes
- Local:
MyWidget() : super(box: () => MyBox());
- Scoped: provide high in the tree and resolve via
BoxScope.of<T>(context)
MastroBox lifecycle & options
Overridables:
init()
— called once when the box is constructed (callsuper.init()
if overridden).dispose()
— idempotent cleanup (callsuper.dispose()
).- View hooks:
onViewAttached(ViewMetaData metadata)
andonViewDetached(ViewMetaData metadata)
fire as views mount/unmount. Useful for ref counts and auto‑cleanup.
Creating a Box
class NotesBox extends MastroBox<NotesEvent> {
final notes = <Note>[].mastro;
// Optional: simple methods instead of events
void addNote(String title) => notes.modify((s) => s.value.add(Note(title)));
}
Actions with or without Events
- Without events: call methods on the box for straightforward logic.
- With events: define
MastroEvent<BoxType>
subclasses to opt into concurrency controls, back-blocking, and loose callbacks.
Creating Events (optional)
sealed class NotesEvent extends MastroEvent<NotesBox> {
const NotesEvent();
const factory NotesEvent.add(String title) = _AddNote;
const factory NotesEvent.load() = _Load;
}
class _AddNote extends NotesEvent {
final String title; const _AddNote(this.title);
@override
Future<void> implement(NotesBox box, Callbacks callbacks) async {
box.addNote(title);
callbacks.invoke('toast', data: {'msg': 'Note added'});
}
}
class _Load extends NotesEvent {
const _Load();
@override
EventRunningMode get mode => EventRunningMode.sequential;
@override
Future<void> implement(NotesBox box, Callbacks _) async {
// fetch & assign
}
}
Running Events
Both execute(event)
and executeBlockPop(context, event)
return Future<void>
— you can await
execution to chain actions or to ensure ordering in your widget logic:
// Common signatures:
// Future<void> execute(event, {Callbacks? callbacks, EventRunningMode? mode})
// Future<void> executeBlockPop(context, event, {Callbacks? callbacks, EventRunningMode? mode})
await box.execute(
const NotesEvent.add('New Note'),
callbacks: Callbacks.on('toast', (data) => showToast(data?['msg'])),
);
await box.executeBlockPop(
context,
const NotesEvent.load(),
mode: EventRunningMode.solo,
);
EventRunningMode
parallel
(default): run freely.sequential
: events of this type are queued and executed one at a time (FIFO).solo
: per‑type exclusivity — duplicates of the same SOLO type are ignored while one runs (different SOLO types may run concurrently).
Box Tagging & Loose Callbacks
// Tagging (UI ping)
box.tag(tag: 'refresh-notes');
MastroZone(
tag: 'refresh-notes',
builder: (context) => NotesList(notes: box.notes.value),
);
// Loose callbacks
box.registerCallback(key: 'toast', callback: (data) {
final msg = data?['msg'] as String? ?? 'Done';
showSnackBar(msg);
});
// from event
callbacks.invoke('toast', data: {'msg': 'Saved ✅'});
// cleanup
box.unregisterCallback(key: 'toast');
Widget Building
MastroZone
Constructor (key parameters):
MastroZone({Key? key, String? tag, required Widget Function(BuildContext) builder, bool Function()? shouldRebuild})
MastroZone<User>(
builder: (context) => Text('Hello ${box.profile.value.name}'),
);
// Or with tag:
MastroZone(
tag: 'refresh-notes',
builder: (context) => NotesList(notes: box.notes.value),
);
TriggerableZone
Constructor (key parameters):
TriggerableZone({Key? key, required TriggerableZoneController controller, required Widget Function(Key key) builder})
final controller = TriggerableZoneController();
TriggerableZone(
controller: controller,
builder: (key) => MyForm(key: key),
);
// Trigger rebuild
controller.trigger();
LifecycleZone
Constructor (key parameters):
LifecycleZone({Key? key, VoidCallback? onViewCreated, VoidCallback? onViewDestroyed, required Widget child})
LifecycleZone(
onViewCreated: () => print('Created'),
onViewDestroyed: () => print('Destroyed'),
child: MyWidget(),
);
MastroHooks (back-blocking UX)
Provide an OnPopHook
so executeBlockPop
can trigger a callback when user try to pop during an event (e.g, showing a “Please wait…” message while an event is running).
MastroHooks(
onPopScope: OnPopHook(
onPopWaitMessage: (context) => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please wait…')),
),
),
child: MaterialApp(home: const HomeWidget()),
);
MastroWidget (view glue & lifecycle)
Generic is mandatory: class MyWidget extends MastroWidget<MyBox> { ... }
Constructors:
- Local:
MyWidget() : super(box: () => MyBox());
- Scoped:
const MyWidget();
(and provideMyBox
viaBoxScope
)
Overridables:
initState(BuildContext context, T box)
/dispose(BuildContext context, T box)
onResume
,onInactive
,onPaused
,onHide
,onDetached
(app lifecycle)- Box receives:
onViewAttached(ViewMetaData metadata)
/onViewDetached(ViewMetaData metadata)
as the view mounts/unmounts
Box resolution order:
- If a local factory is provided → use it.
- Else →
BoxScope.of<T>(context)
.
Providers placement with MaterialApp (important)
It is recommended to place MastroHooks
and your global BoxScope
/ MultiBoxScope
above your MaterialApp
(or in MaterialApp.builder
).
Why? Because home:
lives inside the Navigator
that MaterialApp
creates. A provider placed inside home:
only wraps that first route. As soon as you navigate ( push
, showDialog
, showModalBottomSheet
, etc.), new routes won’t see those providers.
Recommended
void main() {
runApp(
MastroHooks(
onPopScope: OnPopHook(onPopWaitMessage: (c) { /* ... */ }),
child: BoxScope(
factory: (context) => AppBox(),
child: MaterialApp(
home: const HomeWidget(),
),
),
),
);
}
Also OK: use MaterialApp.builder
MaterialApp(
builder: (context, child) => MultiBoxScope(
scopes: [BoxScope.scoped(factory: (context) => AppBox())],
child: child!,
),
home: const HomeWidget(),
);
Memory Leak Detection
Call MastroMemoryLeaksChecker.start()
once at application startup, before runApp()
.
Public API Reference (Quick Links)
Links point to the official API on pub.flutter-io.cn.
Core containers
State helpers
AsyncState<T>
- Extensions:
StateTools
,BoolTools
,MutableCall
Persistence
Boxes & events
Widget glue & providers
MastroZone
TriggerableZone
LifecycleZone
MastroHooks
•OnPopHook
MastroWidget<T>
BoxScope<T extends MastroBox>
•MultiBoxScope
Debugging & diagnostics
Internal utilities
FAQ
Do I have to use Events?
No. You can call box methods directly for simple logic. Use events when you want orchestration: concurrency modes, back‑blocking (executeBlockPop
), and loose callbacks.
Where should I place a box that must survive pushReplacement
?
Provide it above your MaterialApp
(e.g., wrap the app with MultiBoxScope
). This keeps the box alive across route replacements.
How do I avoid unnecessary rebuilds?
Via MastroZone()
. Use shouldRebuild
to short‑circuit rerenders.
What’s the difference between .value
and .modify(...)
?
Use .value = newValue
for simple replacement. Use .modify(...)
to batch in‑place edits (lists/maps/objects) and notify exactly once at the end (validators/observers also run once).
When do I need notify()
?
Rarely. It’s a Basetro
method that manually notifies listeners without changing .value
.
Does computed
update automatically?
Yes — a computed Mastro<R>
updates when its source changes. Dependencies are captured implicitly during computation.
How do I persist a nested object?
Use PersistroLightro.json
or PersistroMastro.json
and supply fromJson
/toJson
for the type. For collections, use list<T>
/map<T>
factories.
Will scoped boxes auto-dispose?
Yes, when the BoxScope
is removed from the tree. Resources are cleaned via Autowire
.
I need a “safe read” on a late state.
Use .safe
to get a nullable view of the current value; on first paint it’s null
until initialized or use .when(uninitialized: () => ..., initialized: (value) => ...)
.
Design Patterns & Recipes
Can I turn off or on the logs?
Yes, you can control logging in Mastro using the global function showMastroLogs()
to enable logs globally.
Thin Events, Fat Methods
Keep feature logic in box methods. Use events only for orchestration (modes, callbacks, block‑back).
Batch saves with autoSave: false
Prefer autoSave: false
when you mutate many times in a row; call persist()
once at the end.
Back‑blocking only for critical ops
Reserve executeBlockPop
for actions that must finish or be cancelled explicitly (e.g., payment submit).
Tags for cheap refresh
Use MastroZone
when you need to refresh a section without introducing a dedicated state.
Can I await events?
Yes — both execute
and executeBlockPop
return Future<void>
.
How do I derive from multiple states?
Use computed(() { ... })
. Dependencies are forwarded implicitly.
Examples
Check the example
folder for more detailed examples of how to use Mastro in your Flutter app.
Contributions
Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request on GitHub.
License
MIT © Yousef Shaiban