Loading...
Loading...
Flutter state management patterns — decision tree for setState, Provider, Riverpod, and BLoC with concrete examples and testing strategies
npx skill4agent add generaljerel/chalk-skills flutter-state-managementWhat kind of state?
├── Ephemeral / UI-only (toggle, animation, form field)?
│ └── setState ✓
├── Shared between a few widgets in a subtree?
│ ├── Simple (1-2 values)? → InheritedWidget or ValueNotifier ✓
│ └── Moderate? → Provider / ChangeNotifier ✓
├── App-wide state (auth, theme, user preferences)?
│ ├── Small app? → Provider ✓
│ └── Medium/large app? → Riverpod ✓
├── Complex async flows (pagination, search, real-time)?
│ ├── Prefer declarative/reactive? → Riverpod ✓
│ └── Prefer event-driven with strict patterns? → BLoC ✓
└── Need offline-first or complex sync?
└── BLoC or Riverpod + Repository pattern ✓class LikeButton extends StatefulWidget {
const LikeButton({super.key, required this.initialCount});
final int initialCount;
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
late int _count;
bool _isLiked = false;
void initState() {
super.initState();
_count = widget.initialCount;
}
void _toggleLike() {
setState(() {
_isLiked = !_isLiked;
_count += _isLiked ? 1 : -1;
});
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: _toggleLike,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isLiked ? Icons.favorite : Icons.favorite_border,
color: _isLiked ? Colors.red : Colors.grey,
),
const SizedBox(width: 4),
Text('$_count'),
],
),
);
}
}// models/cart_model.dart
class CartModel extends ChangeNotifier {
final List<Product> _items = [];
List<Product> get items => List.unmodifiable(_items);
int get totalItems => _items.length;
double get totalPrice => _items.fold(0, (sum, item) => sum + item.price);
void add(Product product) {
_items.add(product);
notifyListeners();
}
void remove(Product product) {
_items.remove(product);
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}// main.dart
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
ChangeNotifierProvider(create: (_) => AuthModel()),
// ProxyProvider for dependent providers
ProxyProvider<AuthModel, UserProfileModel>(
update: (_, auth, previous) =>
UserProfileModel(userId: auth.currentUser?.id),
),
],
child: const MyApp(),
),
);
}// Read once (does NOT rebuild on change)
final cart = context.read<CartModel>();
cart.add(product);
// Watch (rebuilds when model changes)
Widget build(BuildContext context) {
final totalItems = context.watch<CartModel>().totalItems;
return Badge(count: totalItems, child: const Icon(Icons.shopping_cart));
}
// Select (rebuilds only when selected value changes)
Widget build(BuildContext context) {
final totalPrice = context.select<CartModel, double>((c) => c.totalPrice);
return Text('\$${totalPrice.toStringAsFixed(2)}');
}
// Consumer widget (scoped rebuild)
Consumer<CartModel>(
builder: (context, cart, child) {
return Text('${cart.totalItems} items');
},
)// Simple value provider
final appNameProvider = Provider<String>((ref) => 'My App');
// State provider (simple mutable state)
final counterProvider = StateProvider<int>((ref) => 0);
// FutureProvider (async data)
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.getUser(userId);
});
// StreamProvider (real-time data)
final messagesProvider = StreamProvider<List<Message>>((ref) {
final repo = ref.watch(chatRepoProvider);
return repo.watchMessages();
});
// NotifierProvider (complex state with methods)
final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>(
TodosNotifier.new,
);// notifiers/todos_notifier.dart
class TodosNotifier extends Notifier<List<Todo>> {
List<Todo> build() {
// Initial state — can also be async with AsyncNotifier
return [];
}
void add(String title) {
state = [
...state,
Todo(id: const Uuid().v4(), title: title),
];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo,
];
}
void remove(String id) {
state = state.where((t) => t.id != id).toList();
}
}class UsersNotifier extends AsyncNotifier<List<User>> {
Future<List<User>> build() async {
final api = ref.watch(apiClientProvider);
return api.getUsers();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final api = ref.read(apiClientProvider);
return api.getUsers();
});
}
Future<void> addUser(CreateUserInput input) async {
final api = ref.read(apiClientProvider);
final newUser = await api.createUser(input);
state = AsyncData([...state.requireValue, newUser]);
}
}
final usersProvider = AsyncNotifierProvider<UsersNotifier, List<User>>(
UsersNotifier.new,
);class UserListPage extends ConsumerWidget {
const UserListPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(usersProvider);
return usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserTile(user: users[index]),
),
);
}
}// Provider that depends on another
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final filter = ref.watch(filterProvider);
final todos = ref.watch(todosProvider);
switch (filter) {
case TodoFilter.all:
return todos;
case TodoFilter.active:
return todos.where((t) => !t.completed).toList();
case TodoFilter.completed:
return todos.where((t) => t.completed).toList();
}
});
// Override for testing
void main() {
runApp(
ProviderScope(
overrides: [
apiClientProvider.overrideWithValue(MockApiClient()),
],
child: const MyApp(),
),
);
}// bloc/auth/auth_event.dart
sealed class AuthEvent {}
class AuthLoginRequested extends AuthEvent {
AuthLoginRequested({required this.email, required this.password});
final String email;
final String password;
}
class AuthLogoutRequested extends AuthEvent {}
class AuthStatusChecked extends AuthEvent {}
// bloc/auth/auth_state.dart
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
AuthAuthenticated({required this.user});
final User user;
}
class AuthUnauthenticated extends AuthState {}
class AuthFailure extends AuthState {
AuthFailure({required this.message});
final String message;
}
// bloc/auth/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({required AuthRepository authRepo})
: _authRepo = authRepo,
super(AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthStatusChecked>(_onStatusChecked);
}
final AuthRepository _authRepo;
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final user = await _authRepo.login(event.email, event.password);
emit(AuthAuthenticated(user: user));
} catch (e) {
emit(AuthFailure(message: e.toString()));
}
}
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await _authRepo.logout();
emit(AuthUnauthenticated());
}
Future<void> _onStatusChecked(
AuthStatusChecked event,
Emitter<AuthState> emit,
) async {
final user = await _authRepo.getCurrentUser();
if (user != null) {
emit(AuthAuthenticated(user: user));
} else {
emit(AuthUnauthenticated());
}
}
}// Providing
BlocProvider(
create: (context) => AuthBloc(authRepo: context.read<AuthRepository>())
..add(AuthStatusChecked()),
child: const AuthGate(),
)
// Consuming
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return switch (state) {
AuthInitial() || AuthLoading() => const LoadingScreen(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthUnauthenticated() => const LoginPage(),
AuthFailure(:final message) => ErrorPage(message: message),
};
},
);
}
}
// Side effects with BlocListener
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: const LoginForm(),
)blocTestbloc_testblocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthAuthenticated] on successful login',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => testUser);
return AuthBloc(authRepo: mockRepo);
},
act: (bloc) => bloc.add(
AuthLoginRequested(email: 'test@test.com', password: 'pass'),
),
expect: () => [
isA<AuthLoading>(),
isA<AuthAuthenticated>().having((s) => s.user, 'user', testUser),
],
);setStatesetState// BAD: Mutating list in place — listeners never fire
class CartModel extends ChangeNotifier {
final List<Product> items = [];
void add(Product p) {
items.add(p); // Mutation — no notification
}
}
// GOOD: New list triggers notification
void add(Product p) {
_items = [..._items, p];
notifyListeners();
}