Skip to content

swipelab/stated

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

91 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

stated

Small, composable primitives for reactive state, async task sequencing, DI, and lightweight widget rebuilding in Flutter.

pub version license

✨ What is this?

stated is a minimal toolkit that lets you build structure without ceremony:

  • A Stated<T> base for lazily computed immutable view state
  • Simple builder widgets (StatedBuilder, FutureStatedBuilder, BlocBuilder)
  • Reactive primitives (Emitter, ValueEmitter, LazyEmitter, ListEmitter)
  • Multi-source subscriptions (Subscription / SubscriptionBuilder)
  • Async task sequencing & cancellation (Tasks mixin)
  • Debouncing utilities (debounce, Debouncer)
  • An ultra–lean service locator / DI container (Store) with sync, lazy, transient and async init support
  • A small event bus (Publisher) with type filtering
  • URI pattern parsing & canonicalisation (UriParser, PathMatcher)
  • Deterministic resource disposal (Dispose, Disposable)

You can adopt one piece at a time. Nothing forces a framework-wide migration.

🧠 Core Concepts

Concept Summary
Stated<T> Lazily builds a value via buildState(). Notifies only if value changes.
StatedBuilder Creates & listens to a Listenable (disposes if disposable).
FutureStatedBuilder Awaits async creation of a Stated before building.
BlocBuilder Simple create-once builder for any object (optionally disposable).
Emitter Mixin exposing notifyListeners(). Basis for all reactive primitives.
ValueEmitter<T> Mutable value + change notifications.
LazyEmitter<T> Computes value on demand & caches until dependencies trigger update.
Emitter.map Combine multiple Listenables into a derived ValueListenable.
ListEmitter<T> A List<T> implementation emitting on structural changes.
Subscription Aggregate multiple listenables with optional select & when filters.
Tasks Sequential async queue with cancellation tokens.
debounce() Wraps a callback with delayed execution.
Store Register: direct instance, lazy async, transient factory. Resolve via get<T>() or resolve<T>().
AsyncInit Optional mixin for async post-construction initialisation in lazy factories.
Publisher<T> Fire events & listen by subtype.
UriParser / PathMatcher Declarative path pattern matching with typed extraction.

πŸš€ Quick Start

Add to pubspec.yaml:

dependencies:
  stated: ^3.1.7 # use latest

Counter with Stated

import 'package:flutter/material.dart';
import 'package:stated/stated.dart';

// Immutable view state
class CounterState {
  const CounterState(this.count);
  final int count;
}

class CounterBloc extends Stated<CounterState> {
  int _count = 0;
  void increment() => notifyListeners(() => _count++); // calls buildState afterwards
  @override
  CounterState buildState() => CounterState(_count);
}

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
        home: Scaffold(
          body: Center(
            child: StatedBuilder<CounterBloc>(
              create: (_) => CounterBloc(),
              builder: (_, bloc, __) => GestureDetector(
                onTap: bloc.increment,
                child: Text('Count: ${bloc.value.count}'),
              ),
            ),
          ),
        ),
      );
}

🧩 Builders

// StatedBuilder: rebuilds when Listenable changes
StatedBuilder<MyBloc>(
  create: (ctx) => MyBloc(),
  builder: (ctx, bloc, child) => Text(bloc.value.title),
);

// Provide externally managed instance
StatedBuilder.value(existingBloc, builder: ...);

// Async creation
FutureStatedBuilder<MyState>(
  future: (ctx) async => MyAsyncBloc(),
  builder: (ctx, state, child) => Text(state.toString()),
);

// Simple life-cycle wrapper (no listening)
BlocBuilder<ExpensiveService>(
  create: (ctx) => ExpensiveService(),
  builder: (ctx, service, _) => ...,
);

πŸ”„ Reactive Primitives

ValueEmitter

final counter = ValueEmitter<int>(0);
counter.addListener(() => print('now: ${counter.value}'));
counter.value++; // triggers

Derived values with Emitter.map + debounce

final a = ValueEmitter(1);
final b = ValueEmitter(2);
final sum = Emitter.map([a, b], debounce(() => a.value + b.value));
sum.addListener(() => print('sum: ${sum.value}'));
a.value = 10; b.value = 30; // debounced single recompute

LazyEmitter manual invalidation

final derived = LazyEmitter(() => heavyCompute());
// attach derived.update to dependencies:
someListenable.addListener(derived.update);

ListEmitter

final todos = ListEmitter<String>();
todos.addListener(() => print('changed: ${todos.length}'));
todos.add('Write docs');

Subscription

SubscriptionBuilder(
  register: (sub) => sub
      .add(counter, select: (_) => counter.value) // only when value changes
      .add(todos, when: (l) => l.length.isOdd),
  builder: (_, __) => Text('reactive block'),
);

⏱️ Task Queue (Sequential Async)

class Loader with Tasks, Dispose {
  Future<void> loadFiles(List<String> ids) async {
    for (final id in ids) {
      await enqueue(() async { /* await network for id */ });
    }
  }
  @override
  void dispose() { cancelTasks(); super.dispose(); }
}

Inside a cancellable task use the provided token:

await enqueueCancellable((token) async {
  final data = await fetch();
  token.ensureRunning(); // throws if cancelled
  process(data);
});

πŸ›°οΈ Debounce

final search = ValueEmitter('');
final runSearch = debounce(() { print('Query: ${search.value}'); }, const Duration(milliseconds: 300));
search.addListener(runSearch);

πŸ“¦ Store (Service Locator / DI)

Register services:

final store = Store()
  ..add(Logger())                                 // instance
  ..addLazy<Database>((r) async => Database())    // lazy singleton (async ok)
  ..addTransient<HttpClient>((l) => HttpClient());// new each call

await store.init(); // pre-warm lazy (optional)

final logger = store.get<Logger>();       // sync (must be initialised)
final db = await store.resolve<Database>(); // safe async

Support async init phase:

class SessionManager with AsyncInit {
  Future<void> init() async { /* load tokens */ }
}
store.addLazy<SessionManager>((r) async => SessionManager());

πŸ“£ Publisher (Event Bus)

sealed class AppEvent {}
class UserLoggedIn extends AppEvent { UserLoggedIn(this.userId); final String userId; }
class UserLoggedOut extends AppEvent {}

final events = Publisher<AppEvent>();

events.on<UserLoggedIn>().addListener(() => print('login event'));

events.publish(UserLoggedIn('42'));

🌐 URI Parsing

final parser = UriParser<String, void>(
  routes: [
    UriMap('/users/{id:#}', (m) => 'User #${m.pathParameters['id']}'),
    UriMap.many(['/posts/{slug:w}', '/blog/{slug:w}'], (m) => 'Post ${m.pathParameters['slug']}'),
  ],
  canonical: {
    'lang': (raw) => switch(raw) { 'en-US' => 'en', 'en' => 'en', _ => null },
  },
);

parser.parse(Uri.parse('/users/123'), null); // => 'User #123'

Patterns:

  • {field} word / dash / underscore
  • {field:#} digits
  • {field:w} word chars
  • {field:*} greedy
  • {*} wildcard segment

πŸ§ͺ Testing Patterns

All primitives are pure Dart / Flutter-friendly. Example:

test('lazy factory resolves only once', () async {
  final store = Store();
  var created = 0;
  store.addLazy<int>((r) async => ++created);
  expect(await store.resolve<int>(), 1);
  expect(await store.resolve<int>(), 1);
});

For Stated just mutate via notifyListeners wrapper:

class Flag extends Stated<bool> { bool _v=false; void toggle()=>notifyListeners(()=>_v=!_v); @override bool buildState()=>_v; }

πŸ†š Comparison (High Level)

Library Focus Philosophy
provider DI + Inherited Widget-driven
riverpod Compile-safe reactive graph Opinionated, layered
bloc Event/state pattern Structured flows
stated Tiny primitives Compose only what you need

Use stated when you want low ceremony & control, or to augment existing setups.

🍳 Cookbook

Combine multiple counters into a derived state

final a = ValueEmitter(0);
final b = ValueEmitter(0);
final sum = Emitter.map([a, b], () => a.value + b.value);

Debounced text field

onChanged: (value) { text.value = value; }, // where text is ValueEmitter<String>
text.addListener(debounce(() => search(text.value)));

Cancel pending tasks on dispose

class Loader with Tasks, Dispose {
  Future<void> refresh() => enqueue(() async { /* network */ });
  @override void dispose() { cancelTasks(); super.dispose(); }
}

❓ FAQ

Why not use ChangeNotifier directly? Stated<T> formalizes immutable snapshot building & avoids redundant notifications.

Does Store replace Provider? It is a minimal locatorβ€”use it alongside Provider if you like.

Is this production ready? The code is intentionally small; review and adopt incrementally.

🀝 Contributing

Issues & PRs welcome. Please keep features focused & composable.

🧭 Possible Next Steps / Roadmap

These are intentionally not included yet to keep scope tight, but may be explored:

  • DevTools integration helpers (inspecting Stated trees)
  • Flutter extension widgets for Provider / InheritedWidget bridging
  • Async task progress utilities (percent / state enum)
  • Stream adapters (Emitter <-> Stream)
  • Code generation for DI registration (optional layer)
  • More collection emitters (MapEmitter, SetEmitter)
  • Documentation site with interactive examples
  • Lint rules to encourage immutable state models

πŸ“„ License

MIT - see LICENSE

πŸ“œ Changelog

See CHANGELOG.md


If this library helps you, consider starring the repo so others can find it.

About

final state

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published