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.

  • Mobile & Desktop
  • $flutter-state-management
  • 206 lines
  • 2 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- flutter-state-management

Installs to ~/.agents/skills/flutter-state-management/ for every project on this machine.

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

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.

~/.agents/skills/flutter-state-management/SKILL.md

--- name: flutter-state-management description: Chooses and implements Flutter state management with Riverpod 3 or Bloc - generated providers, Notifier and AsyncNotifier, AsyncValue loading and… license: MIT ---

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

SituationPickWhy
Most apps, small to mid teams, lots of async dataRiverpod 3 with code generationCompile-safe DI plus state, caching, auto-dispose, little boilerplate
Large team wanting explicit event logs and strict layeringBloc (flutter_bloc)Events are an auditable contract; mature testing; enforced structure
Ephemeral UI state (a toggle, a text field, an animation)StatefulWidget / hooksNot 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.watch in build methods; ref.read in callbacks; ref.listen for side effects. Never ref.read a provider in build to “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, StateProvider and ChangeNotifierProvider are legacy in Riverpod 3 (moved to package:flutter_riverpod/legacy.dart). Migrate them to Notifier when touched.
  • Check ref.mounted after an await inside a notifier before setting state.

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 to Bloc when you need event transformers (debounce search with restartable(), drop duplicate submits with droppable() from bloc_concurrency) or an event audit trail.
  • Use BlocBuilder with buildWhen, or context.select, to limit rebuilds; BlocListener for navigation and snackbars; BlocConsumer when you need both.
  • Provide blocs with BlocProvider(create: ...) at the lowest route that needs them so they close automatically. BlocProvider.value does not close the bloc; use it only to pass an existing instance down.
  • State classes must have value equality (equatable or records) or identical states will rebuild, and distinct behaviour breaks.

Where state lives

StateOwner
Text field contents, focus, animation progressWidget (StatefulWidget)
Screen data loaded from an APIRoute-scoped provider/bloc, auto-disposed
Filters that survive navigation within a featureFeature-scoped provider/bloc
Signed-in user, feature flags, settingsApp-lifetime provider/bloc
Server cacheRepository 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_runner after changing a @riverpod signature gives confusing type errors; keep watch running.
  • Creating a ProviderContainer or bloc inside build recreates state on every rebuild.
  • emit after a bloc is closed throws; check isClosed in long async handlers, or use emit.forEach / emit.onEach for 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. Check context.mounted before 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem