Build Status codecov

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

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 touches Navigator.

_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 enclosing ControlledSubtree. A nested ControlledSubtree hides everything the outer one registered and must re-register what its widgets need. This is why screen sections normally use a nested BaseController sharing the same container instead of a nested ControlledSubtree.

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

Libraries

state
Reactive state, and the widgets that observe it.
subtree
Controllers and the widget lifecycle around them.