state_forge 0.1.8
state_forge: ^0.1.8 copied to clipboard
Structured Flutter state management with typed stores, scoped rebuilds, effects, persistence, and test helpers.
StateForge ⚒️ #
A feature-scoped state layer for Flutter apps. Zero boilerplate. Zero code generation. Zero ceremony.
One file per feature. Direct method calls. No events. No
build_runner. No waiting.
Table of Contents #
- Why StateForge
- Mental Model
- The 10-Second Proof
- Quick Start
- Core Concepts
- Power Features
- State Patterns
- Testing
- Performance
- When Not to Use StateForge
- Comparison
- Migrating
- Resources
- License
Why StateForge #
StateForge is for Flutter features that should stay small without becoming implicit or hard to test. You can adopt it one feature at a time instead of replacing your whole app architecture.
BLoC emphasizes explicit event-driven architecture. Riverpod emphasizes provider composition and strong dependency modeling. Provider keeps close to Flutter's widget tree. StateForge focuses on a narrower goal:
Build a feature in one or two files, with direct method calls, predictable state transitions, scoped rebuilds, and first-class one-time effects.
No event classes. No generated state classes. No build_runner step required.
Stores are scoped by default and live in the widget tree. They are only global
when you choose to mount them at the app root.
Mental Model #
Widget
│ reads / selects / listens
▼
Store
├─ state ──> rebuild UI
└─ effect ──> one-time navigation, snackbars, analytics
│
▼
Repository / API
The 10-Second Proof #
This is a complete login feature pattern. It keeps the state layer small while still separating state changes from one-time effects.
// login_store.dart — your entire state layer for this feature
class LoginStore extends Store<AsyncState<User>> {
LoginStore(this.api) : super(const Idle());
final AuthApi api;
Future<void> login(String email, String password) async {
emit(const Loading());
await guard(() async {
final user = await api.login(email, password);
emit(Success(user));
effect('navigate_home'); // fire-and-forget side effect
});
}
}
// login_page.dart — the UI
class LoginPage extends StoreWidget<LoginStore, AsyncState<User>> {
@override
Widget buildStore(BuildContext context, LoginStore store, AsyncState<User> state) {
return state.when(
idle: () => LoginForm(onSubmit: store.login),
loading: () => const CircularProgressIndicator(),
success: (user) => WelcomeScreen(user: user),
failure: (error) => ErrorView(error: error, onRetry: store.login),
);
}
}
That is the complete feature. 2 files. No event class. No generated state
class. No build_runner step.
See the BLoC equivalent for comparison
Depending on team conventions, the same feature in BLoC often uses:
login_event.dart— LoginRequested event classlogin_state.dart— LoginInitial, LoginLoading, LoginSuccess, LoginFailurelogin_bloc.dart— Bloc class +on<LoginRequested>()handler- Optional DI setup — register bloc and repository
login_page.dart—BlocProvider+BlocBuilder+BlocListener
StateForge compresses that shape when you want direct command methods instead of an event pipeline.
Quick Start #
1. Add to pubspec.yaml
dependencies:
state_forge: ^0.1.8
2. Define your Store
class CounterStore extends Store<int> {
CounterStore() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
3. Provide it
// Single store
StoreProvider<CounterStore>(
create: (_) => CounterStore(),
child: const CounterPage(),
)
// Multiple stores at app root
ForgeMultiProvider(
providers: [
StoreProvider<AuthStore>(create: (_) => AuthStore()),
StoreProvider<CartStore>(create: (_) => CartStore()),
],
child: const MyApp(),
)
4. Use it
// Option A: Extend StoreWidget (cleanest)
class CounterPage extends StoreWidget<CounterStore, int> {
@override
Widget buildStore(BuildContext context, CounterStore store, int count) {
return Text('$count');
}
}
// Option B: context.watch inline
final count = context.watch<CounterStore>().state;
Text('$count');
// Option C: ForgeBuilder for granular control
ForgeBuilder<CounterStore, int>(
builder: (context, state, store) => Text('$state'),
)
Core Concepts #
AsyncState — Async Without the Boilerplate #
AsyncState<T> is a built-in sealed class that models the four states every async operation has. No need to write your own Loading, Success, Failure classes.
class ProductStore extends Store<AsyncState<List<Product>>> {
ProductStore(this.repo) : super(const Idle());
final ProductRepository repo;
Future<void> load() async {
emit(const Loading());
await guard(() async {
final products = await repo.fetchAll();
emit(Success(products));
});
}
}
// In your widget — exhaustive, compiler-enforced
state.when(
idle: () => const EmptyState(),
loading: () => const ShimmerList(),
success: (products) => ProductGrid(products: products),
failure: (error) => RetryButton(onTap: store.load),
);
// Or use .map() for optional handling
state.maybeWhen(
success: (products) => ProductGrid(products: products),
orElse: () => const LoadingSpinner(),
);
Selective Rebuilds — ForgeSelector #
When a shared store (cart, auth, profile) changes, only the widgets that care
should rebuild. ForgeSelector lets you subscribe to a slice of state and
rebuild only when that selected value changes.
// Only rebuilds when item count changes — not on price updates, not on item edits
ForgeSelector<CartStore, CartState, int>(
select: (state) => state.itemCount,
builder: (context, count, store) => CartBadge(count: count),
)
// Multi-field selection via Dart Records (structural equality for free)
ForgeSelector<CartStore, CartState, (int, double)>(
select: (state) => (state.itemCount, state.total),
builder: (context, record, store) {
final (count, total) = record;
return CartSummary(count: count, total: total);
},
)
The context.select<T, S, R>() extension follows the same selected-value
semantics for inline widget code.
Side Effects — First Class #
Side effects (navigation, snackbars, analytics) are not UI state — they should
never live in your state class. StateForge provides a dedicated effect()
stream so you never have to hack your state to trigger a one-time event.
Effects are consumed once by listeners and are not replayed just because a
widget rebuilds.
// In your store
Future<void> placeOrder() async {
emit(const Loading());
await guard(() async {
final order = await repo.place(state.cart);
emit(Success(order));
effect(OrderPlaced(order.id)); // fire-and-forget
effect(TrackAnalytics('purchase')); // multiple effects allowed
});
}
// In your widget — reacts without rebuilding
ForgeListener<CartStore, CartEffect>(
onEffect: (context, effect) => switch (effect) {
OrderPlaced(:final orderId) => context.go('/confirmation/$orderId'),
TrackAnalytics(:final event) => Analytics.track(event),
},
child: const CartPage(),
)
Scoped Lifecycle — Stores Die When Screens Die #
StateForge uses the widget tree for store lifecycle. A store lives for as long as its provider is mounted, then it is disposed automatically.
// Screen-scoped: auto-disposed when LoginPage leaves the tree
StoreProvider<LoginStore>(
create: (_) => LoginStore(api: AuthApi()),
child: const LoginPage(),
)
// App-scoped: lives for the app session — place at MaterialApp
StoreProvider<AuthStore>(
create: (_) => AuthStore(),
child: MaterialApp(home: const HomePage()),
)
// Shared: pass an existing instance to a subtree (wizard flows, sibling tabs)
StoreProvider<CheckoutStore>.value(
store: existingCheckoutStore,
child: const StepThreePage(),
)
Power Features #
UndoableStore — Undo/Redo in One Line #
class DrawingStore extends Store<DrawingState> with UndoableStore<DrawingState> {
DrawingStore() : super(DrawingState.empty());
void addStroke(Stroke stroke) => emit(state.withStroke(stroke));
// undo(), redo(), canUndo, canRedo — automatically available
}
// In your widget
IconButton(
onPressed: store.canUndo ? store.undo : null,
icon: const Icon(Icons.undo),
)
PersistableStore — Opt-In Persistence #
class SettingsStore extends Store<SettingsState> with PersistableStore<SettingsState> {
SettingsStore() : super(SettingsState.defaults()) {
hydrateOnCreate(debounce: const Duration(milliseconds: 250));
}
@override
String get storageKey => 'settings';
@override
SettingsState fromJson(Map<String, dynamic> json) => SettingsState.fromJson(json);
@override
Map<String, dynamic> toJson(SettingsState state) => state.toJson();
}
Configure storage once at app startup:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
StateForge.storage = await SharedPreferencesForgeStorage.create();
runApp(const App());
}
state_forge keeps storage as an adapter contract. Use
state_forge_shared_preferences for
lightweight local JSON, or swap in
Hive, Drift,
encrypted storage, files, or cloud sync by implementing ForgeStorageAdapter.
If you need manual control, you can still call hydrate(), persist(), and
persistOnChange() yourself.
CompositedStore — Clean Cross-Store Dependencies #
Cross-store dependencies are declared at the widget level and injected via constructor, which keeps store relationships visible and easy to test.
// CartStore needs to know who's logged in
StoreProvider<CartStore>(
create: (context) => CartStore(
repo: CartRepository(),
authStore: context.read<AuthStore>(), // reads parent store cleanly
),
child: const CartPage(),
)
Lifecycle rule: constructor-injected store dependencies should be stable for as
long as the dependent store lives. For app-wide stores like auth, cart, and
profile, keep the store instance mounted and model changes as state transitions
(LoggedIn -> LoggedOut) instead of recreating the store instance. If a
dependency truly must be replaced, recreate the dependent store in the same
scope or wire the relationship explicitly with CompositedStore.
When a parent store instance is intentionally replaceable, use
StoreProxyProvider so the dependent store can be recreated or explicitly
rebound:
StoreProxyProvider<AuthStore, CartStore>(
create: (context, auth) => CartStore(authStore: auth),
update: (context, auth, cart) => cart.rebindAuth(auth),
child: const CartPage(),
)
Global Auditing — ForgeObserver #
For enterprise teams that need a full audit trail of every state transition across the app (compliance, debugging, analytics):
void main() {
StateForge.observer = ForgeObserver(
onEmit: (store, prev, next) =>
print('[${store.runtimeType}] $prev → $next'),
onEffect: (store, effect) =>
Analytics.track('effect', {'store': store.runtimeType, 'effect': effect}),
onError: (store, error, stack) =>
Sentry.captureException(error, stackTrace: stack),
);
runApp(const MyApp());
}
State Patterns #
StateForge supports two state patterns — use whichever fits your feature.
Pattern A: Sealed Variants #
Best for: auth flows, async data loading, multi-step wizards — anywhere with clearly distinct modes.
sealed class AuthState {}
class AuthIdle extends AuthState { const AuthIdle(); }
class AuthLoading extends AuthState { const AuthLoading(); }
class AuthSuccess extends AuthState { const AuthSuccess(this.user); final User user; }
class AuthFailure extends AuthState { const AuthFailure(this.message); final String message; }
// Compiler enforces all cases are handled — no codegen needed
return switch (state) {
AuthIdle() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthSuccess(:final user) => WelcomeScreen(user: user),
AuthFailure(:final message) => ErrorView(message: message),
};
Pattern B: Data State with Named Transitions #
Best for: profile editing, settings, checkout — anywhere with many fields that update independently.
class ProfileState {
const ProfileState({this.name = '', this.email = '', this.isLoading = false, this.error});
final String name;
final String email;
final bool isLoading;
final String? error;
// Named transitions document WHY the state changed
ProfileState loading() => ProfileState(name: name, email: email, isLoading: true);
ProfileState withName(String n) => ProfileState(name: n, email: email);
ProfileState withEmail(String e) => ProfileState(name: name, email: e);
ProfileState withError(String e) => ProfileState(name: name, email: email, error: e);
}
Testing #
Stores are plain classes with no BuildContext dependency — no WidgetTester,
no pumpWidget(), no async frame gymnastics. The store core lives in
state_forge_core, so business logic can also be tested from pure Dart packages.
// Plain unit test — runs in milliseconds
test('login emits Loading then Success', () async {
final store = LoginStore(api: MockAuthApi());
final states = <AsyncState<User>>[];
store.addListener(() => states.add(store.state));
await store.login('user@example.com', 'password');
expect(states[0], isA<Loading>());
expect(states[1], isA<Success<User>>());
});
Or use the included forgeTest utility for a declarative style:
forgeTest<CounterStore, int>(
'emits [1, 2] when incremented twice',
build: () => CounterStore(),
act: (store) async {
store.increment();
store.increment();
},
expect: () => [1, 2],
);
Performance #
StateForge stores are pure Dart listenables, and the Flutter package exposes them
through InheritedModel for scoped widget-tree access.
StateForge minimizes rebuild work by:
- Batching asynchronous store notifications into a microtask
- Looking up stores through scoped inherited widgets instead of global listeners
- Rebuilding selector widgets only when their selected value changes
- Keeping one-time effects separate from state so effects do not force state rebuilds
Focused rebuild behavior is covered by tests.
Local benchmark and stress-test scenarios live in
benchmark_suite/.
When Not to Use StateForge #
StateForge is intentionally focused on feature-scoped state and direct store methods. It may not be the best fit when:
- Your team is already standardized on BLoC, Riverpod, or Provider and the current architecture is working well
- You need strict event-sourcing semantics where every state transition must be modeled as a domain event
- Your app is mostly a provider graph or dependency graph problem rather than a feature-state problem
- You want generated provider APIs, compile-time provider wiring, or a larger ecosystem around dependency injection
Comparison #
This table is about default ergonomics and package focus, not a claim that other tools cannot model the same workflows.
| StateForge | BLoC | Riverpod | Provider | |
|---|---|---|---|---|
| Primary unit | Store | Bloc / Cubit | Provider | ChangeNotifier / value |
| Code generation required | No | No | No | No |
| Generated APIs available | No | Optional via ecosystem | Optional | No |
| Async state helper | ✅ AsyncState |
Common via custom states | ✅ AsyncValue |
Custom |
| One-time effects | ✅ Dedicated effect stream | Common via listener patterns | Common via listeners/ref | Custom |
| Selective rebuilds | ✅ ForgeSelector, context.select |
✅ BlocSelector |
✅ select |
✅ Selector |
| Pure Dart core | ✅ state_forge_core |
✅ | ✅ | 〜 Flutter-oriented |
| Typical feature shape | Direct store methods | Event/command handlers | Provider graph | Notifier methods |
Need to pass a store to a Flutter API that requires Listenable? Use the
interop adapter:
final listenable = counterStore.asListenable();
Migrating #
- From BLoC → Move from event handlers to direct store methods.
- From Riverpod → Move feature state into explicit store objects.
- From GetX → Keep low ceremony while making dependencies explicit.
Resources #
- Complete User Guide — Deep dive into every feature
- Benchmark Suite — Local rebuild and stress-test scenarios
- DevTools Source — Source for the bundled DevTools extension
- Example App — Movie watchlist demo with auth, search, details, profile, and persistence
License #
StateForge is open-source under the MIT License.
Built for Flutter developers who want small feature files without giving up predictable state transitions.