yasml

Yet Another State Management Library for Flutter.

yasml makes every state transition explicit, traceable, and compiler-verified. There is no runtime reflection and no implicit rebuilds. The type system enforces the architecture: if it compiles, the data flow is correct.

The constitution

  1. Queries describe data. A query is a pure descriptor — it says what to fetch, never when.
  2. Commands describe mutations. A command executes a side-effect and declares which queries it invalidates.
  3. Compositions describe views. A composition watches queries and projects them into view-model state.
  4. Mutations describe intent. A mutation is the only API the UI touches — it orchestrates commands on behalf of the user.
  5. The World settles. After every mutation the world waits until every affected query has refetched and every composition has re-evaluated before returning control. No partial updates. No race conditions.
  6. The compiler is the guardian. ViewWidget<T, C, M> generics tie state, composition, and mutation together — wire them wrong and the code does not compile.

Table of contents


Quick start — a todo list in five pieces

Every yasml app has exactly five moving parts: a Query, a Command, a Composition, a Mutation, and a View. This example fetches todos from a REST API and lets the user toggle completion — enough to see the full reactive loop in action.

// A simple model for the example.
class Todo {
  const Todo({required this.id, required this.title, required this.completed});

  factory Todo.fromJson(Map<String, dynamic> json) => Todo(
    id: json['id'] as int,
    title: json['title'] as String,
    completed: json['completed'] as bool,
  );

  final int id;
  final String title;
  final bool completed;
}

final Dio dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));

1. Query — where does the data live?

final todosQuery = FutureQuery<List<Todo>>.create(
  (world) async {
    final response = await dio.get<List<dynamic>>('/todos?_limit=10');
    return response.data!
        .map((dynamic e) => Todo.fromJson(e as Map<String, dynamic>))
        .toList();
  },
  key: 'TodosQuery',
);

FutureQuery.create takes a function Future<T> Function(World) and a cache key. The function says what to fetch — in this case, ten todos from a REST API. The key uniquely identifies this query for caching and invalidation. Because the data is async, the composition state will be wrapped in AsyncValue<T>.

2. Command — how does the data change?

Command<void> toggleTodo(Todo todo) => Command.create(
  (world) async {
    await dio.patch<void>('/todos/${todo.id}', data: {'completed': !todo.completed});
  },
  (_) => [todosQuery],
);

toggleTodo is a function that returns a new Command. The first argument is the execute function (the side-effect — a PATCH request), the second declares which queries the command invalidates. After execution, the world automatically refetches every listed query, so the todo list updates with the new completion state.

3. Composition — what does the view need?

final todosComposition = AsyncComposition<List<Todo>>.create(
  (composer) => composer.watchFuture(todosQuery),
  key: 'TodosComposition',
);

AsyncComposition.create watches one or more async queries and projects them into view-model state. When any watched query is invalidated, the composition re-runs automatically. watchFuture subscribes to a FutureQuery and surfaces its AsyncValue<T> lifecycle.

4. Mutation — what can the user do?

base class TodosMutation extends Mutation<AsyncComposition<List<Todo>>> {
  const TodosMutation({required super.commander});

  Future<void> toggle(Todo todo) => commander.dispatch(toggleTodo(todo));
}

Mutations are the only way the UI triggers state changes. They are always class-based because they define named methods — the typed contract between the view and the state layer. commander.dispatch executes the command, collects invalidations, and waits for the world to settle before returning.

5. View — put it on screen

base class TodosView
    extends ViewWidget<AsyncValue<List<Todo>>, AsyncComposition<List<Todo>>, TodosMutation> {
  const TodosView({required super.world, super.key});

  @override
  AsyncComposition<List<Todo>> get composition => todosComposition;

  @override
  MutationConstructor<TodosMutation> get mutationConstructor =>
      (commander) => TodosMutation(commander: commander);

  @override
  Widget build(
    BuildContext context,
    AsyncValue<List<Todo>> state,
    Notifier<TodosMutation> notifier,
  ) {
    return switch (state) {
      AsyncLoading() => const Center(child: CircularProgressIndicator()),
      AsyncError(:final error) => Center(child: Text('Error: $error')),
      AsyncData(:final data) => ListView.builder(
        itemCount: data.length,
        itemBuilder: (context, index) {
          final todo = data[index];
          return CheckboxListTile(
            value: todo.completed,
            title: Text(todo.title),
            onChanged: (_) => notifier.runMutation((m) => m.toggle(todo)),
          );
        },
      ),
    };
  }
}

ViewWidget<T, C, M> binds three types together:

  • T — the composition state type (AsyncValue<List<Todo>>)
  • C — the composition (AsyncComposition<List<Todo>>)
  • M — the mutation (TodosMutation)

Get any of these wrong and the compiler rejects the code.

AsyncValue<T> is a sealed class with three subtypes — AsyncLoading, AsyncData, and AsyncError. Dart's exhaustive switch ensures you handle every state at compile time.

The Notifier<M> record gives you two capabilities:

  • runMutation — execute a mutation and wait for the world to settle
  • refresh — manually re-fetch all queries the composition watches

Bootstrapping the world

void main() {
  final world = World.create(plugins: [], observers: []);
  runApp(MaterialApp(home: Scaffold(body: TodosView(world: world))));
}

The World is the root container. Create it once, pass it to your views.


The reactive loop

Every interaction follows the same cycle:

  View displays Composition state
       │
       ▼
  User triggers notifier.runMutation(...)
       │
       ▼
  Mutation dispatches Command(s)
       │
       ▼
  Command.execute() performs side-effect
  Command.invalidate() marks Queries stale
       │
       ▼
  Invalidated Queries automatically refetch
       │
       ▼
  World waits until ALL queries have settled
       │
       ▼
  Compositions re-evaluate with new query data
       │
       ▼
  View rebuilds with updated state
       │
       ▼
  runMutation future completes — world is settled

The critical guarantee: runMutation does not return until the world has completely settled. There is no intermediate state where some queries have updated and others have not.


Keys & identity

Every query and composition requires a key — a string that uniquely identifies it in the world's internal registry. Two query objects with the same key are the same query. This is what allows the world to cache, deduplicate, and invalidate correctly.

With the functional API, the key is a parameter you provide:

final countQuery = SynchronousQuery.create(
  (world) => count,
  key: 'CountQuery',
);

For parameterized queries (see next section), include the parameter in the key so each variant is cached separately:

FutureQuery<User> userById(String id) => FutureQuery.create(
  (world) async { /* ... */ },
  key: 'UserByIdQuery/$id',
);

userById('1') and userById('2') produce queries with different keys — they are independent cache entries. Invalidating one does not affect the other.


Parameterized queries

Queries are descriptors — lightweight objects you create wherever you need them. To make a query depend on input data, wrap the factory in a function:

FutureQuery<User> userById(String id) => FutureQuery.create(
  (world) async {
    final response = await world.dio.get('/users/$id');
    return User.fromJson(response.data);
  },
  key: 'UserByIdQuery/$id',
);

The parameter is captured in the closure and included in the key. Each call creates a query with the right identity — the caching system deduplicates by key.

Pass parameters through the composition the same way:

AsyncComposition<User> userComposition(String userId) => AsyncComposition.create(
  (composer) => composer.watchFuture(userById(userId)),
  key: 'UserComposition/$userId',
);

The view wires it together — the composition getter is the injection point:

base class UserDetailView
    extends ViewWidget<AsyncValue<User>, AsyncComposition<User>, UserMutation> {
  final String userId;
  const UserDetailView({super.key, required super.world, required this.userId});

  @override
  AsyncComposition<User> get composition => userComposition(userId);

  @override
  MutationConstructor<UserMutation> get mutationConstructor =>
      (commander) => UserMutation(commander: commander);

  @override
  Widget build(BuildContext context, AsyncValue<User> state, Notifier<UserMutation> notifier) {
    return switch (state) {
      AsyncLoading() => Center(child: CircularProgressIndicator()),
      AsyncError(:final error) => ErrorWidget(error),
      AsyncData(:final data) => Text(data.name),
    };
  }
}

Async queries

When data comes from a network call or database, use FutureQuery<T>. The composition state becomes AsyncValue<T>, giving you compile-time exhaustive handling of loading, error, and data states.

FutureQuery

final asyncCountQuery = FutureQuery.create(
  (world) async {
    final response = await world.dio.get('/count');
    return response.data['count'] as int;
  },
  key: 'AsyncCountQuery',
);

You provide a Future<T> Function(World). The library handles cancellation, state transitions (AsyncLoadingAsyncData or AsyncError), and settlement signalling.

AsyncComposition

final asyncCountComposition = AsyncComposition.create(
  (composer) => composer.watchFuture(asyncCountQuery),
  key: 'AsyncCountComposition',
);

AsyncComposition.create composes async queries. Use composer.watchFuture(query) for FutureQuery and composer.watchStream(query) for StreamQuery. The composition state is AsyncValue<T>.

The view handles every state

base class AsyncCounterView
    extends ViewWidget<AsyncValue<int>, AsyncComposition<int>, AsyncCountMutation> {
  const AsyncCounterView({super.key, required super.world});

  @override
  AsyncComposition<int> get composition => asyncCountComposition;

  @override
  MutationConstructor<AsyncCountMutation> get mutationConstructor =>
      (commander) => AsyncCountMutation(commander: commander);

  @override
  Widget build(
    BuildContext context,
    AsyncValue<int> composition,
    Notifier<AsyncCountMutation> notifier,
  ) {
    return switch (composition) {
      AsyncLoading() => Center(child: CircularProgressIndicator()),
      AsyncError(:final error) => ErrorWidget(error),
      AsyncData(:final data) => Scaffold(
        body: Center(child: Text(data.toString())),
        floatingActionButton: FloatingActionButton(
          onPressed: () => notifier.runMutation((m) => m.increment(data)),
          child: Icon(Icons.plus_one),
        ),
      ),
    };
  }
}

AsyncValue<T> is a sealed class with three subtypes — AsyncLoading, AsyncData, and AsyncError. Dart's exhaustive switch ensures you handle every state at compile time.


Stream queries

For real-time data (WebSockets, game loops, Firestore snapshots), use StreamQuery<T>.

final gameQuery = StreamQuery<GameState>.create(
  (world, setSettled) {
    final stream = world.game.stream(Duration(milliseconds: 100));
    stream.first.then((_) => setSettled());
    return stream;
  },
  key: 'GameQuery',
);

Unlike FutureQuery, a StreamQuery receives a setSettled callback. You decide when the query should be considered settled — typically after the first emission. Each subsequent emission updates the state and notifies compositions.

Stream compositions use watchStream:

final gameComposition = AsyncComposition<GameState>.create(
  (composer) => composer.watchStream(gameQuery),
  key: 'GameComposition',
);

Commands that don't invalidate

Not every command needs to trigger a refetch. Stream-driven queries update via the stream itself, so the command can return an empty invalidation list:

final gameClickCommand = Command<void>.create(
  (world) { world.game.click(); },
  (_) => [],
);

The command fires a side-effect (sending a click event into the game loop), and the StreamQuery picks up the resulting state change through the stream.


Reading queries from mutations

Mutations can read the current value of any query via commander.read:

base class SomeMutation extends Mutation<SomeComposition> {
  const SomeMutation({required super.commander});

  Future<void> doSomething() async {
    final currentUser = await commander.read(userById('1'));
    await commander.dispatch(updateUser(currentUser.id, {'active': true}));
  }
}

read subscribes to the query, waits for it to settle, reads the value, and unsubscribes — all in one call.


Plugins — extending the World

Plugins hook into the world lifecycle to initialize and clean up external resources. A common use case is exposing an HTTP client:

final class DioPlugin implements WorldPlugin {
  late final Dio dio;

  @override
  void onInit(World world) {
    dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
  }

  @override
  Future<void> onDispose() async {
    dio.close();
  }
}

Expose the plugin through a typed extension on World so queries can access it naturally:

extension DioPluginExtension on World {
  Dio get dio {
    final plugin = pluginByType<DioPlugin>();
    if (plugin == null) {
      throw StateError('DioPlugin was not found on the World');
    }
    return plugin.dio;
  }
}

Now any query can use world.dio to make HTTP calls:

FutureQuery<User> userById(String id) => FutureQuery.create(
  (world) async {
    final response = await world.dio.get('/users/$id');
    return User.fromJson(response.data);
  },
  key: 'UserByIdQuery/$id',
);

Register plugins at world creation:

final world = World.create(
  plugins: [DioPlugin()],
  observers: [],
);

Observers — reacting to events

Observers receive every event the world emits. Use them for analytics, crash reporting, or custom devtools.

class MyObserver implements Observer {
  @override
  void onInit(World world) {}

  @override
  Future<void> onDispose() async {}

  @override
  void onEvent(Event event) {
    switch (event) {
      case QueryInvalidatedEvent():
        print('Query invalidated: ${event.componentName}');
      case MutationExecutedEvent():
        print('Mutation executed: ${event.componentName}');
      default:
        break;
    }
  }
}

The Event hierarchy is sealed and covers every lifecycle moment: world creation/destruction/settlement, query execution/invalidation, composition execution, mutation dispatch, command execution, and view creation/disposal.

Register observers at world creation:

final world = World.create(
  plugins: [],
  observers: [MyObserver()],
);

A LoggingObserver is included automatically in every world — you only need to enable the log levels.


Debugging & logging

yasml uses Dart's logging package with hierarchical loggers. Enable them selectively:

import 'package:logging/logging.dart';
import 'package:yasml/yasml.dart';

void main() {
  hierarchicalLoggingEnabled = true;

  // Enable all yasml logs
  yasmlLog.level = Level.ALL;

  // Or target specific subsystems
  queryLog.level = Level.FINE;
  mutationLog.level = Level.INFO;
  worldLog.level = Level.INFO;

  Logger.root.onRecord.listen((record) {
    print('${record.level.name}: ${record.loggerName}: ${record.message}');
  });

  final world = World.create(plugins: [DioPlugin()], observers: []);
  runApp(MyApp(world: world));
}

Logger hierarchy

Logger Exported as What it logs
yasml yasmlLog Root — enables everything below
yasml.world worldLog World lifecycle, settling
yasml.world.query QueryManager operations
yasml.world.composition CompositionManager operations
yasml.query queryLog QueryContainer: fetch, state changes, invalidation
yasml.composition compositionLog CompositionContainer: compose, watch
yasml.mutation mutationLog MutationContainer: command dispatch
yasml.command commandLog Command execution and invalidation
yasml.view viewLog View creation, rebuild, disposal

Class-based API — when you need it

The functional factories (.create) cover the vast majority of use cases. All queries, commands, and compositions can alternatively extend the corresponding base classes:

base class CountQuery extends SynchronousQuery<int> {
  @override
  String get key => (CountQuery).toString();

  @override
  int query(World world) => count;
}
class UpdateCountCommand implements Command<void> {
  final int newValue;
  UpdateCountCommand({required this.newValue});

  @override
  FutureOr<void> execute(World world) {
    count = newValue;
  }

  @override
  List<Query<dynamic>> invalidate(void result) => [CountQuery()];
}
base class CountComposition extends SynchronousComposition<int> {
  @override
  String get key => (CountComposition).toString();

  @override
  int compose(Composer composer) => composer.watch(CountQuery());
}

The class-based approach trades brevity for two things the functional API cannot provide:

  • Stricter type discrimination. Two functional compositions that return the same type (e.g. SynchronousComposition<int>) are interchangeable in the type system — the compiler cannot stop you from passing the wrong one to a view. A named class like CountComposition is its own type, so ViewWidget<int, CountComposition, CountMutation> rejects any other composition at compile time.
  • Refactor-safe keys. Deriving the key from the class type literal — (CountComposition).toString() — means renaming the class automatically updates the key. With the functional API, keys are plain strings that you maintain by hand.

The class-based API is fully interchangeable with the functional API — both produce objects that the world handles identically.


API reference

Query types

Factory You provide State type Use when
SynchronousQuery.create T Function(World), key T Data is available immediately
FutureQuery.create Future<T> Function(World), key AsyncValue<T> Data comes from an async call
StreamQuery.create Stream<T> Function(World, VoidCallback), key AsyncValue<T> Data is a continuous stream

Composition types

Factory You provide Watches via State type
SynchronousComposition.create T Function(Composer), key composer.watch(query) T
AsyncComposition.create Future<T> Function(AsyncComposer), key composer.watchFuture(q) / composer.watchStream(q) AsyncValue<T>

Command

Parameter Purpose
FutureOr<T> Function(World) execute Perform the side-effect
List<Query> Function(T) invalidate Declare which queries are now stale

Mutation

Member Purpose
commander.dispatch(command) Execute a command and collect its invalidations
commander.read(query) Read the current settled value of a query

ViewWidget<T, C, M>

Type parameter Meaning
T The composition state type
C extends Composition<T> The composition type
M extends Mutation<C> The mutation class
Abstract getter/method Purpose
C get composition Provide the composition instance (can pass parameters)
MutationConstructor<M> get mutationConstructor Provide the mutation factory
Widget build(BuildContext, T, Notifier<M>) Build the widget from state and notifier

Notifier<M>

Field Type Purpose
runMutation Future<R> Function<R>(FutureOr<R> Function(M)) Execute a mutation, wait for settlement
refresh Future<void> Function() Re-fetch all watched queries

AsyncValue<T>

Subtype Properties Use
AsyncLoading<T> Query is fetching
AsyncData<T> T data Query succeeded
AsyncError<T> Object error, StackTrace? stackTrace Query failed

Libraries

yasml