Flutter State Management skill for Codex
Riverpod or Bloc, picked once and done right. Codex keeps one state library per app, recommends Riverpod 3 with code generation or Bloc depending on team needs, and gives every piece of state a clear owner and lifetime. Async screens always render loading, data and error, side effects go through listeners instead of build, and logic is unit tested with ProviderContainer overrides or bloc_test.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- flutter-state-management Installs to ~/.agents/skills/flutter-state-management/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install flutter-state-management The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project flutter-state-management Installs to .agents/skills/flutter-state-management/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/flutter-state-management.zipunzip -o flutter-state-management.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) flutter-state-management PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $flutter-state-management, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Chooses and implements Flutter state management with Riverpod 3 or Bloc - generated providers, Notifier and AsyncNotifier, AsyncValue loading and error states, Cubit vs Bloc with sealed events and event transformers, scoping and disposal, side effects with ref.listen and BlocListener, and tests with ProviderContainer and bloc_test. Use when picking a state library for a Flutter app, untangling setState spaghetti, adding async loading and error handling, fixing state that leaks between screens, or testing providers and blocs.
Use it when
- Choosing a state management approach for a new Flutter app
- setState and ChangeNotifier logic has become hard to follow
- Async data needs consistent loading and error handling
- Providers or blocs need tests or leak state between screens
Use something else when
- The problem is widget layout, theming or native calls: use flutter-apps
- You need cross-platform architecture guidance beyond Flutter: use mobile-app-architecture
How to check the result
flutter test passes with notifier or bloc unit tests that need no widgets, and each async screen was forced into loading, data and error states via overrides or fakes.
The mistake it prevents
Using ref.read inside build to avoid rebuilds, which leaves the widget showing stale data when the provider changes.
Done well means: one state approach per app, every piece of state has an obvious owner and lifetime, async state always renders loading, data and error, side effects (snackbars, navigation) are separated from rebuilds, and business logic is tested without widgets.
Inspect first
cat AGENTS.md 2>/dev/null
grep -E 'riverpod|flutter_bloc|bloc|provider|get_it|mobx|signals' pubspec.yaml
grep -rlE 'StateNotifier|ChangeNotifier|Cubit<|Bloc<|@riverpod' lib | head -20
If the app already uses one library consistently, keep it. Mixing Riverpod and Bloc in one codebase doubles the patterns every contributor must learn; only do it during an explicit, planned migration.
Choosing for a new app
| Situation | Pick | Why |
|---|---|---|
| Most apps, small to mid teams, lots of async data | Riverpod 3 with code generation | Compile-safe DI plus state, caching, auto-dispose, little boilerplate |
| Large team wanting explicit event logs and strict layering | Bloc (flutter_bloc) | Events are an auditable contract; mature testing; enforced structure |
| Ephemeral UI state (a toggle, a text field, an animation) | StatefulWidget / hooks | Not app state; do not lift it into a global store |
ValueNotifier + ListenableBuilder is fine for a single widget subtree. GetX and
global mutable singletons are not recommended for new work: they hide dependencies and
make testing hard.
Riverpod 3
Setup: flutter_riverpod, riverpod_annotation, dev deps riverpod_generator,
build_runner, and riverpod_lint (via custom_lint). Wrap the app in ProviderScope.
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'orders.g.dart';
@riverpod
OrdersApi ordersApi(Ref ref) => OrdersApi(ref.watch(httpClientProvider));
@riverpod
class Orders extends _$Orders {
@override
Future<List<Order>> build({required String status}) => // family parameter
ref.watch(ordersApiProvider).fetch(status: status);
Future<void> cancel(String id) async {
await ref.read(ordersApiProvider).cancel(id);
ref.invalidateSelf(); // refetch
await future;
}
}
dart run build_runner watch -d
class OrdersScreen extends ConsumerWidget {
const OrdersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(ordersProvider(status: 'open'), (prev, next) {
if (next case AsyncError(:final error)) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$error')));
}
});
return switch (ref.watch(ordersProvider(status: 'open'))) {
AsyncData(:final value) => OrdersList(orders: value),
AsyncError(:final error) => ErrorView(error: error),
_ => const Center(child: CircularProgressIndicator()),
};
}
}
Rules:
ref.watchinbuildmethods;ref.readin callbacks;ref.listenfor side effects. Neverref.reada provider inbuildto “avoid rebuilds”: it silently goes stale.- Generated providers auto-dispose by default. Use
@Riverpod(keepAlive: true)only for app-lifetime services (auth session, HTTP client). - Narrow rebuilds with
ref.watch(provider.select((s) => s.count)). - Riverpod 3 retries failing providers automatically and pauses listeners of off-screen widgets; account for the retry when you assert on error states.
StateNotifier,StateProviderandChangeNotifierProviderare legacy in Riverpod 3 (moved topackage:flutter_riverpod/legacy.dart). Migrate them toNotifierwhen touched.- Check
ref.mountedafter anawaitinside a notifier before settingstate.
Bloc
sealed class CartEvent {}
final class CartItemAdded extends CartEvent { CartItemAdded(this.item); final Item item; }
final class CartCheckedOut extends CartEvent {}
sealed class CartState { const CartState(); }
final class CartReady extends CartState { const CartReady(this.items); final List<Item> items; }
final class CartSubmitting extends CartState { const CartSubmitting(); }
final class CartFailed extends CartState { const CartFailed(this.message); final String message; }
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc(this._repo) : super(const CartReady([])) {
on<CartItemAdded>((e, emit) {
final s = state;
if (s is CartReady) emit(CartReady([...s.items, e.item]));
});
on<CartCheckedOut>(_onCheckout, transformer: droppable()); // ignore double taps
}
final CartRepository _repo;
Future<void> _onCheckout(CartCheckedOut e, Emitter<CartState> emit) async {
final items = (state as CartReady).items;
emit(const CartSubmitting());
try {
await _repo.checkout(items);
emit(const CartReady([]));
} catch (err) {
emit(CartFailed('$err'));
}
}
}
- Start with a
Cubit(methods instead of events). Promote toBlocwhen you need event transformers (debounce search withrestartable(), drop duplicate submits withdroppable()frombloc_concurrency) or an event audit trail. - Use
BlocBuilderwithbuildWhen, orcontext.select, to limit rebuilds;BlocListenerfor navigation and snackbars;BlocConsumerwhen you need both. - Provide blocs with
BlocProvider(create: ...)at the lowest route that needs them so they close automatically.BlocProvider.valuedoes not close the bloc; use it only to pass an existing instance down. - State classes must have value equality (
equatableor records) or identical states will rebuild, anddistinctbehaviour breaks.
Where state lives
| State | Owner |
|---|---|
| Text field contents, focus, animation progress | Widget (StatefulWidget) |
| Screen data loaded from an API | Route-scoped provider/bloc, auto-disposed |
| Filters that survive navigation within a feature | Feature-scoped provider/bloc |
| Signed-in user, feature flags, settings | App-lifetime provider/bloc |
| Server cache | Repository layer, exposed through providers/blocs |
State that leaks between screens almost always means an app-lifetime owner for something that should be route-scoped (or a family parameter missing).
Testing
test('cancel refetches open orders', () async {
final api = FakeOrdersApi(orders: [order1, order2]);
final container = ProviderContainer.test(
overrides: [ordersApiProvider.overrideWithValue(api)],
);
await container.read(ordersProvider(status: 'open').future);
await container.read(ordersProvider(status: 'open').notifier).cancel(order1.id);
expect(container.read(ordersProvider(status: 'open')).value, [order2]);
});
blocTest<CartBloc, CartState>(
'checkout emits submitting then empty cart',
build: () => CartBloc(FakeCartRepository()),
seed: () => CartReady([item]),
act: (b) => b.add(CartCheckedOut()),
expect: () => [isA<CartSubmitting>(), isA<CartReady>()],
);
Use fakes over mocks for repositories; they keep tests readable when signatures change.
Gotchas
- Forgetting to rerun
build_runnerafter changing a@riverpodsignature gives confusing type errors; keepwatchrunning. - Creating a
ProviderContaineror bloc insidebuildrecreates state on every rebuild. emitafter a bloc is closed throws; checkisClosedin long async handlers, or useemit.forEach/emit.onEachfor streams.- Exposing mutable lists in state and mutating them in place means no change is detected. Always emit new collections.
- Async gaps: after
await, the widget may be gone. Checkcontext.mountedbefore navigation from callbacks.
Verification
dart run build_runner build -d # Riverpod codegen is current
flutter analyze # riverpod_lint / bloc_lint findings included if configured
flutter test
Done when every async screen shows loading, data and error states (force each with a fake or override), state is disposed when leaving the route, and notifier/bloc logic has unit tests that run without pumping widgets.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Flutter State Management"
short_description: "Riverpod or Bloc, picked once and done right"
default_prompt: "$flutter-state-management Should this Flutter app use Riverpod or Bloc? Set up whichever fits" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.