subtree 0.6.1
subtree: ^0.6.1 copied to clipboard
A state manager for those who like BLoC but don't like BLoC verbosity.
Subtree #
A state manager for Flutter developers who like BLoC but not its verbosity.
A screen is three things: a state class of reactive fields, an actions interface, and a controller that implements the actions and fills the state. Widgets read both out of the widget tree. No code generation, no streams, no sealed state hierarchies — controllers are regular Dart classes.
Subtree is built entirely on standard Flutter interfaces — Listenable and
ValueListenable. Rx, RxList and RxEvent are ValueListenable
implementations, and sync/subscribe/ref.watch accept any Listenable. So
ValueNotifier, ChangeNotifier, AnimationController, a TextEditingController
or any third-party observable work as-is, with no adapters.
| BLoC | Subtree |
|---|---|
| Events are separate objects | Actions are plain method calls |
BlocBuilder + BlocProvider |
Obx + context.get<T>() |
| State is a sealed class hierarchy | State is a class with Rx fields |
| Verbose setup | Minimal wiring |
Contents #
- Installation
- Quick start
- How a screen is put together
- The pieces — state · watching state · controllers · the container · sync & subscribe · ControlledSubtree
- Patterns from production apps
- Testing
- Gotchas
Installation #
dependencies:
subtree: ^0.6.1
import 'package:subtree/subtree.dart'; // controllers, ControlledSubtree, context.get
import 'package:subtree/state.dart'; // Rx, RxList, RxEvent, Obx, EventListener
Quick start #
// counter_model.dart — what the screen shows, and what it can do.
class CounterState {
final count = Rx<int>(0);
}
abstract class CounterActions {
void increment();
}
// counter_controller.dart — the only place with logic.
class CounterController extends SubtreeController implements CounterActions {
final state = CounterState();
CounterController() {
subtree.put(state);
subtree.put<CounterActions>(this);
}
@override
void increment() => state.count.value++;
}
// counter_page.dart — reads state and calls actions. Nothing else.
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
final state = context.get<CounterState>();
final actions = context.get<CounterActions>();
return Scaffold(
body: Center(child: Obx((ref) => Text('${ref.watch(state.count)}'))),
floatingActionButton: FloatingActionButton(
onPressed: actions.increment,
child: const Icon(Icons.add),
),
);
}
}
// Wherever the screen is created — a route, a tab, runApp().
ControlledSubtree(
subtree: const CounterPage(),
controller: (context) => CounterController(),
)
How a screen is put together #
Every screen in a Subtree app is the same three files. Keeping the split is what makes the pattern pay off — the page has no logic, and the controller has no Flutter widgets.
counter/
counter_model.dart CounterState + abstract CounterActions
counter_controller.dart CounterDependencies, CounterRouting, CounterController
counter_page.dart CounterPage and its private widgets
_model.dart — a plain class of Rx fields, plus an abstract class …Actions listing everything the user can do. No imports from Flutter, no logic.
This file is the contract between the other two: the page depends on it, the
controller implements it, and neither depends on the other.
_controller.dart — the controller, plus the two small structs it is
constructed with:
…Dependencies— the repositories, APIs and services it needs.…Routing— callbacks for everything that leaves the screen (navigate here, show that error). The controller never touchesNavigator.
_page.dart — widgets. They resolve context.get<XState>() and
context.get<XActions>() at the top of build, wrap the parts that depend on
state in Obx, and hand callbacks straight to the actions.
Data flows one way:
ControlledSubtree
│ creates
▼
XController ──────► XState (Rx fields)
▲ │ watched by
│ calls ▼
XActions ◄────────── XPage
State: Rx, RxList, RxEvent #
A state class is just fields. There is no base class to extend.
class ProfileState {
final name = Rx<String>('');
final avatarUrl = Rx<String?>(null);
final isLoading = Rx<bool>(false);
final filters = Rx<Set<String>>({});
final errors = RxList<String>([]);
}
Rx<T> — a single value. Assigning an equal value (by ==) does nothing, so
mirroring the same data twice does not rebuild widgets. Use update when the
value is mutated rather than replaced:
state.name.value = 'Ada'; // notifies
state.name.value = 'Ada'; // no-op, equal
state.filters.update((f) => f.add('unread')); // mutated in place → always notifies
RxList<T> — a list that is always unmodifiable, compared element-wise:
state.items.value = await repo.load(); // replace
state.items.update((items) => [...items, x]); // derive
state.items.value[0] = x; // throws
RxEvent<T> — a signal, not state. Every emit notifies, even with the same
value. Read it with EventListener (see below).
You are not required to use any of them. Anything implementing ValueListenable
works with ref.watch, and anything implementing Listenable works with
sync/subscribe:
class FormState {
final email = TextEditingController(); // plain Flutter, watchable
final progress = ValueNotifier<double>(0); // plain Flutter, watchable
}
Watching state: Obx and ref.watch #
Obx rebuilds when a value read through ref.watch changes — and nothing else
rebuilds:
Obx((ref) => Text(ref.watch(state.name)))
Wrap the smallest subtree that depends on the value. A page that watches everything at the top rebuilds everything.
Dependencies are collected on each build, so watching inside a branch is fine:
Obx((ref) {
if (ref.watch(state.isLoading)) return const CircularProgressIndicator();
return ItemList(items: ref.watch(state.items));
})
Derived state. Pass the ref into a method to compute values from several
observables while keeping the subscriptions. This works well as a method on the
state class itself:
class EditExerciseState {
final title = Rx<String>('');
final videoUrl = Rx<String?>(null);
bool isValid(ReactiveBlockRef ref) =>
ref.watch(title).isNotEmpty && ref.watch(videoUrl) != null;
}
Obx((ref) => SaveButton(enabled: state.isValid(ref)))
The same trick keeps big pages readable — split build into helpers that take
ReactiveBlockRef ref and call them from one Obx.
StateObx<T> resolves state and watches it in one step, for widgets that
need nothing else:
StateObx<CounterState>((state, ref) => Text('${ref.watch(state.count)}'))
ref.disableUntilCompleted returns null while an async action is still
running, which Flutter renders as a disabled button — a one-line guard against
double submits:
Obx((ref) => ElevatedButton(
onPressed: ref.disableUntilCompleted(actions.save),
child: const Text('Save'),
))
Pass a stable function reference (actions.save, not an inline closure) —
identity is what identifies the in-flight action.
ReactiveBlock is Obx without a widget: it runs a function immediately and
re-runs it whenever anything it watched changes. Useful inside a controller when
listing dependencies by hand would be tedious. Call dispose() on it yourself.
Controllers #
SubtreeController — the controller of a screen. It owns a subtree
container and registers into it whatever the widgets need:
class ProfileController extends SubtreeController implements ProfileActions {
final state = ProfileState();
final ProfileDependencies deps;
final ProfileRouting routing;
ProfileController({required this.deps, required this.routing}) {
subtree.put(state);
subtree.put<ProfileActions>(this);
_load();
}
@override
void dispose() {
// release your own resources here
super.dispose(); // cancels every sync/subscribe
}
}
Constructors do the initial work. Kicking off an un-awaited _load() is the
normal thing to do — the page renders whatever the state says in the meantime.
BaseController — a controller without its own container, for a section of
a screen. It takes the enclosing screen's container and writes into it, so its
state and actions resolve from anywhere on that screen. See
splitting a screen into sections.
The container: put and context.get #
subtree.put(x) registers x under its static type; context.get<T>() resolves
it from the widget tree.
subtree.put(state); // key: ProfileState
subtree.put<ProfileActions>(this); // key: ProfileActions, not ProfileController
subtree.put(deps.userRepository); // expose a repository to leaf widgets
Registering the controller under its interface is the point: the page depends
on ProfileActions, so any implementation will do — the real controller, a mock
in a test, a demo stub.
put returns what it stored, which is handy in a field initialiser:
state = subtree.put(AppState(localizer: localizer));
Two rules worth remembering:
- One object per type. Putting the same type twice throws. If you need two of something, wrap them in distinct types.
- Lookup does not walk outwards.
context.get<T>()searches only the nearest enclosingControlledSubtree. A nestedControlledSubtreehides everything the outer one registered and must re-register what its widgets need. This is why screen sections normally use a nestedBaseControllersharing the same container instead of a nestedControlledSubtree.
Reacting to the outside world: sync and subscribe #
Both attach a callback to a list of Listenables. Both are cancelled when the
controller is disposed.
sync |
subscribe |
|
|---|---|---|
| Runs immediately | yes | no |
| Runs on every change | yes | yes |
| For | keeping state in step with a source | reacting to a change |
sync is how state gets filled. In practice, most controllers are a thin
reactive mirror of a repository or store:
sync(() {
final repo = deps.balanceRepository;
state.balance.value = repo.balance;
state.balanceError.value = repo.balanceError;
state.interestAmount.value = repo.interestAmount;
}, [deps.balanceRepository]);
That single call means "these fields always reflect the repository" — it runs at
construction to populate the first frame, and again on every repository change.
Rx's equality check keeps redundant re-runs from rebuilding widgets.
sync returns a future that completes after the first run, so a caller can wait
for the initial load:
Future<void> _init() async {
await sync(_loadProfile, [deps.userRepository]);
state.ready.value = true;
}
subscribe is for things that must not replay at startup — a refresh when
the user switches account, a navigation after a save completes:
subscribe(() => _refresh(), [deps.accountRepository]);
ControlledSubtree #
The widget that ties it together. It creates the controller, publishes its
container to subtree, and disposes the controller (and all its subscriptions)
when the widget goes away.
ControlledSubtree(
subtree: const CounterPage(),
controller: (context) => CounterController(api: services.counterAPI),
)
Because the two sides only know each other through the container, either can be swapped alone:
ControlledSubtree(
subtree: isDesktop ? const CounterPageDesktop() : const CounterPage(),
controller: (context) => isDemo ? MockCounterController() : CounterController(...),
)
ControlledSubtree is generic over its controller: the example above is a
ControlledSubtree<CounterController>, inferred from the builder. That type is
part of the widget's identity, so replacing one ControlledSubtree with another
that has a different controller type recreates the controller rather than reusing
the old one.
deps recreates the controller when a value it was built from changes — the
same idea as a React hook's dependency list:
ControlledSubtree(
subtree: const OrderPage(),
controller: (context) => OrderController(orderId: orderId),
deps: [orderId],
)
Most screens don't need it. A controller that loads its own data and syncs to
its sources reacts to change without being rebuilt; deps is for when the
controller's identity changes.
Patterns from production apps #
These come from apps built on Subtree, and they are what the library is shaped for.
1. Wire screens where routes are declared #
Give each area of the app a flow object: a plain class holding a navigator and a dependency container, exposing one method per screen. All the wiring lives in one file, and the screens stay unaware of each other.
class HomeFlow {
final NavigatorState navigator;
final HomeDependencies deps;
HomeFlow({required this.navigator, required this.deps});
Widget homeTab() => ControlledSubtree(
subtree: const HomePage(),
controller: (_) => HomeController(
deps: deps,
routing: HomeRouting(
openEditProfile: () => navigator.pushNamed('/edit_profile'),
openSendMoney: (data) => navigator.pushNamed('/send_money', arguments: data),
showContactNotFound: () => _snack('Not a registered contact'),
),
),
);
}
It works the same with a declarative router — the ControlledSubtree goes in the
route builder, where the router's arguments are also in scope:
GoRoute(
path: LoginRoute.path,
builder: (context, state) => ControlledSubtree(
subtree: const LoginPage(),
controller: (context) => LoginController(
deps: LoginDependencies.import(dependencies),
pageArgs: state.extra! as LoginPageArgs,
routing: LoginRouting(
onResetPassword: (login) => ResetPasswordRoute.goTo(context, login: login),
),
),
),
)
2. Controllers never navigate — they call routing #
Everything that leaves the screen goes through a small struct of callbacks passed into the controller:
class LoginRouting {
final void Function(String login) onResetPassword;
LoginRouting({required this.onResetPassword});
}
// in the controller
@override
void resetPassword() => routing.onResetPassword(state.email.value);
The controller stays testable and reusable — the same screen can be pushed as a route, embedded in a tab, or shown in a dialog, and only the flow changes.
3. Return a result to the caller #
For screens that produce a value, put a Completer in the page arguments and
complete it from the controller. Completing it in dispose covers the case where
the user simply backs out.
sealed class LoginResult {}
class LoginSuccess extends LoginResult { ... }
class LoginCancelled extends LoginResult {}
class LoginPageArgs {
final String login;
final Completer<LoginResult> result;
LoginPageArgs({required this.login, required this.result});
}
class LoginRoute {
static Future<LoginResult> goTo(BuildContext context, {required String login}) {
final completer = Completer<LoginResult>();
context.goNamed(name, extra: LoginPageArgs(login: login, result: completer));
return completer.future;
}
}
class LoginController extends SubtreeController implements LoginActions {
// …
@override
void dispose() {
if (!pageArgs.result.isCompleted) {
pageArgs.result.complete(LoginCancelled());
}
super.dispose();
}
}
Callers then read like ordinary async code:
final result = await LoginRoute.goTo(context, login: email);
if (result is LoginSuccess) { ... }
4. Split a big screen into sections #
A screen with several independent blocks gives each one a BaseController that
writes into the screen's container. Every section keeps its own state, actions and
subscriptions, and its widgets resolve them with the usual context.get<T>() —
no nested ControlledSubtree, no prop drilling.
class TopAppBarController extends BaseController implements TopAppBarActions {
final state = TopAppBarState();
TopAppBarController(SubtreeModelContainer subtree, this.deps, this.routing) {
subtree.put(state);
subtree.put<TopAppBarActions>(this);
sync(_syncBalance, [deps.balanceRepository]);
}
}
class HomeController extends SubtreeController implements HomeActions {
late final TopAppBarController _topAppBar;
late final LastActionsController _lastActions;
HomeController({required this.deps, required this.routing}) {
subtree.put(state);
subtree.put<HomeActions>(this);
_topAppBar = TopAppBarController(subtree, TopAppBarDependencies.import(deps),
TopAppBarRouting(openEditProfile: routing.openEditProfile));
_lastActions = LastActionsController(subtree, ...);
}
@override
void dispose() {
_topAppBar.dispose(); // ControlledSubtree disposes only the root
_lastActions.dispose();
super.dispose();
}
}
ControlledSubtree disposes the controller it created and nothing else, so the
owner disposes its sections.
5. One-off signals with RxEvent #
When a page must react to something that isn't part of its visual state, emit an event and listen for it in the tree:
// state
final invalidLinkEvent = RxEvent<Object>();
// controller
state.invalidLinkEvent.emit(error);
// page
EventListener<Object>(
event: state.invalidLinkEvent,
listener: (context, _) => ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Invalid link'))),
child: const ConfirmPage(),
)
Reach for this only when the reaction genuinely needs a BuildContext the
controller doesn't have. When the controller can act itself, a routing callback
is simpler.
6. Put the whole app in a subtree #
The root controller is a good home for things that live as long as the process: app-wide dependencies, the localizer, navigator keys.
void main() {
runApp(ControlledSubtree(
subtree: const AppWidget(),
controller: (context) => AppController(),
));
}
class AppState {
final navigatorKey = GlobalKey<NavigatorState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final Localizer localizer;
AppState({required this.localizer});
}
class AppWidget extends StatelessWidget {
const AppWidget({super.key});
@override
Widget build(BuildContext context) {
final state = context.get<AppState>();
return MaterialApp(navigatorKey: state.navigatorKey, home: const SplashPage());
}
}
Remember that context.get doesn't search outwards: screens below their own
ControlledSubtree can't see AppState. Pass what they need through their
Dependencies struct, or re-register it in the screen's controller.
7. Build your own primitives #
Rx is small enough to build on. Form inputs are the usual case — a validating
text field is a ValueListenable with an error list attached, and it plugs into
ref.watch like anything else:
class RxTextInput extends Rx<String> {
RxTextInput(String value) : super(value);
final errors = RxList<String>([]);
void addError(String message) => errors.update((e) => [...e, message]);
}
class LoginState {
final email = RxEmailInput('');
final password = RxTextInput('');
final formStatus = RxFormStatus();
}
Testing #
A controller is a plain object, so most logic can be tested without a widget
tree at all: construct it with fake dependencies and assert on state.x.value.
For the screen as a whole, pump a ControlledSubtree — this is also how you test
a page against a stub controller:
testWidgets('shows the formatted counter', (tester) async {
final controller = CounterController();
await tester.pumpWidget(MaterialApp(
home: ControlledSubtree(
subtree: const CounterPage(),
controller: (context) => controller,
),
));
expect(find.text('0'), findsOneWidget);
// Widgets drive the controller through the actions interface…
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
expect(find.text('1'), findsOneWidget);
// …and react to state changed from anywhere.
controller.state.count.value = 100;
await tester.pump();
expect(find.text('100'), findsOneWidget);
});
Because pages resolve XActions by interface, a fake controller registering the
same types is a drop-in replacement — no mocking framework required.
Gotchas #
'$myRx' throws. Rx, RxList and RxEvent deliberately throw from
toString(), because interpolating them silently prints an object and skips the
subscription. Write '${ref.watch(state.count)}'.
Assigning an equal value doesn't notify. Rx.value skips the update when the
new value is == to the old one. For values mutated in place, or where ==
doesn't capture the change, use update.
context.get<T>() only sees the nearest subtree. No outward search. If it
throws, either the widget isn't below the right ControlledSubtree, or the
controller never put that type.
One object per type. put throws on a duplicate type — including the case
where two sections of a screen both register the same repository.
Sections are disposed by their owner. ControlledSubtree disposes only the
controller it created; a controller that creates BaseControllers disposes them
in its own dispose.
Obx placement is your rebuild budget. One Obx around a whole page rebuilds
the whole page. Push it down to the widgets that actually read the value.
Choosing a reactive primitive #
| Need | Use |
|---|---|
| A single value widgets watch | Rx<T> |
| A list widgets watch | RxList<T> |
A fire-once signal needing a BuildContext |
RxEvent<T> + EventListener |
| Something Flutter already gives you | ValueNotifier, ChangeNotifier, TextEditingController, … |
Examples #
example/— the counter above, end to end.full_examples/notes_app— repositories,sync, a list and an editor screen.full_examples/flutter_login— an auth flow with a root controller and per-screen routing.