Subtree is a state manager for those who like BLoC but don't like BLoC verbosity.
- Subtree separates state injection from state observing mechanism.
- Providing a compact mechanism for observing state instead of verbose Builder concept.
- Events (Actions) are not separate objects, just regular function calls, which hugely reduce boilerplate.
- Don't try go into all application layers. Stays only on presentation layer.
class ExamplePageState {
final title = ValueNotifier("...");
}
abstract class ExamplePageActions {
void buttonClicked();
}
// .....
void build(BuildContext context) {
final state = context.subtreeGet<ExamplePageState>(); // getting subtree state
final actions = context.subtreeGet<ExamplePageActions>();
// watch on state.title change with ref.watch function.
return Obx((ref) =>
TextButton(
onPressed: actions.buttonClicked, child: Text(
ref.watch(state.title)
)));
}
The state is a simple class, you can put any reactive primitives into it.
Obx
and ref.watch
is designed to reduce the verbosity of ValueListenableBuilder. You can watch any
primitive that implements the ValueListenable interface.
But if you need you can use other reactive primitives and builders for them, like StreamBuilder.
class ExamplePageState {
final title = BehaviorSubject<String>();
}
void build(BuildContext context) {
final state = context.subtreeGet<ExamplePageState>();
return StreamBuilder<String>(
stream: state.title,
builder: (BuildContext context, AsyncSnapshot<String> titleSnapshot) {
...
});
}
It is common practice to use a "counter" example to demonstrate state management usage.
But it is too far from the real use case. Let's a little bit complicate it and assume the counter state is located on the backend.
// CounterAPI doing http call to backend API.
class CounterAPI {
Future<int> getCounterValue();
Future<int> incCounterValue();
Future<int> decCounterValue();
}
// counter_model.dart
class CounterState {
final counter = Rx<int>(0);
final loaded = Rx<bool>(false);
final blocked = Rx<bool>(false);
}
abstract class CounterActions {
void incCounter();
void decCounter();
}
//counter_controller.dart
class CounterController extends SubtreeController implements CounterActions {
final state = CounterState();
@protected
final CounterAPI counterAPI;
CounterController({required this.counterAPI}) {
subtreeModel.putState(state);
subtreeModel.put<CounterActions>(this);
loadData();
}
void loadData() async {
final counter = await counterAPI.getCounterValue();
state.counter.value = counter;
state.loaded.value = true;
}
// Actions implementations
@override
void incCounter() async {
state.blocked.value = true;
final newCounterValue = await counterAPI.incCounterValue();
state.counter.value = newCounterValue;
state.blocked.value = false;
}
@override
void decCounter() async {
// implemented in the same way as incCounter.
}
}
// counter_screen.dart
class CounterScreen extends StatelessWidget {
const CounterScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final state = context.subtreeGet<CounterState>();
final actions = context.subtreeGet<CounterActions>();
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Obx((ref) {
if (!ref.watch(state.loaded)) {
return const Center(child: CircularProgressIndicator());
}
return Stack(children: [
Center(
child: Column(children: [
Text('Counter: ${ref.watch(state.counter)}'),
MaterialButton(
onPressed: actions.incCounter,
child: const Text('+'),
),
MaterialButton(
onPressed: actions.decCounter,
child: const Text('-'),
)
]),
),
if (ref.watch(state.blocked))
...[const Opacity(
opacity: 0.2,
child: ModalBarrier(dismissible: false, color: Colors.black),
),
const Center(child: CircularProgressIndicator())
]
]);
}));
}
}
Now we need to bind all things together. Usually, this is done on the router level.
ControlledSubtree(
subtree: const CounterScreen(),
controller: () => CounterController(counterAPI: services.counterAPI),
);
Because subtree widgets and the controller not depending directly on each other, they can be independently tested. Also, you can have different widgets for different platforms, or special mock controllers with preset data for demo mode.
ControlledSubtree(
subtree: isDesktop ? const CounterScreenDesktop() : const CounterScreen(),
controller: () => isDemo ? MockCounterController() : CounterController(counterAPI: services.counterAPI),
);