State management is the cornerstone of modern Flutter development. It's the difference between a chaotic, unmaintainable codebase and an elegant, scalable architecture that grows with your business. This comprehensive guide explores proven patterns, industry best practices, and production-tested strategies for managing state across Flutter applications of any scale.
Whether you're building a startup MVP or an enterprise-grade platform, understanding state management will elevate your development capabilities and enable you to architect applications that are:
- Performant: Minimal rebuilds, optimized memory usage
- Testable: Pure functions, mockable dependencies
- Maintainable: Clear separation of concerns
- Scalable: Handle complexity without spaghetti code
- Resilient: Proper error handling and state recovery
- State Management Patterns: A Comprehensive Overview
- Decision Matrix: Choosing Your Pattern
- Context & Dependency Injection Deep Dive
- State Persistence & Data Serialization
- Testing Stateful Widgets at Scale
- Performance Optimization Strategies
- Enterprise-Grade Best Practices
setState is Flutter's built-in mechanism for managing widget-level state. It's the simplest approach and forms the foundation for understanding more advanced patterns.
class CounterWidget extends StatefulWidget {
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int count = 0;
void increment() {
setState(() {
count++; // Mark widget as dirty
});
// Framework rebuilds only this widget
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: increment,
child: const Text('Increment'),
),
],
);
}
}- Local Scope: State lives exclusively within the widget
- Direct Manipulation: Modify state directly in callbacks
- Synchronous Updates: UI updates immediately after setState
- Minimal Boilerplate: Zero external dependencies
- Widget Lifecycle: Tightly coupled to widget's lifespan
✓ Zero learning curve for beginners
✓ No external dependencies
✓ Perfect for isolated components
✓ Excellent for teaching Flutter fundamentals
✗ Limited to single widget scope
✗ Poor for sharing state across screens
✗ Difficult to test business logic
✗ Causes unnecessary rebuilds in large trees
✗ Not suitable for complex applications
- Simple, isolated widgets (buttons, toggles, local counters)
- Single-screen features (form validation, input fields)
- Rapid prototyping and learning
- Teaching Flutter basics
Provider elegantly combines dependency injection with reactive programming, offering the perfect balance of simplicity and power for most Flutter applications.
Provider is built on three core concepts:
- InheritedWidget: Flutter's native mechanism for propagating values down the widget tree
- ChangeNotifier: Observable pattern for state changes
- Consumer: Reactive widgets that rebuild when dependencies change
// Step 1: Define your state model
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Trigger rebuilds for all listeners
}
void decrement() {
_count--;
notifyListeners();
}
void reset() {
_count = 0;
notifyListeners();
}
}
// Step 2: Provide to widget tree
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterModel(),
child: const MyApp(),
),
);
}
// Step 3: Consume in UI widgets
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
// Option A: Direct watch + rebuild entire widget
child: Consumer<CounterModel>(
builder: (context, counter, child) {
return Text('Count: ${counter.count}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterModel>().increment(),
child: const Icon(Icons.add),
),
);
}
}Selector for Fine-Grained Performance Control
// Only rebuild when specific property changes
// This is critical for performance in large applications
Selector<CounterModel, int>(
selector: (context, model) => model.count,
builder: (context, count, child) {
debugPrint('Counter widget rebuilt'); // Prints only when count changes
return Text('Count: $count');
},
)ProxyProvider for Combining Dependencies
// Elegantly compose multiple providers
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => UserService()),
// UserViewModel depends on both AuthService and UserService
ChangeNotifierProxyProvider2<AuthService, UserService, UserViewModel>(
create: (context) => UserViewModel(),
update: (context, authService, userService, previousViewModel) {
return previousViewModel
..authService = authService
..userService = userService;
},
),
],
child: const MyApp(),
)FutureProvider for Async Data Loading
// Perfect for API calls, database queries
final userProvider = FutureProvider.autoDispose<User>((ref) async {
final response = await http.get(Uri.parse('https://api.example.com/user/123'));
return User.fromJson(jsonDecode(response.body));
});
class UserScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<AsyncValue<User>>(
builder: (context, userAsync, child) {
return userAsync.when(
data: (user) => Text('Hello, ${user.name}'),
loading: () => const CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
);
},
);
}
}- Flexible: Works with ChangeNotifier, Future, or Stream
- Scalable: Manages complex state hierarchies elegantly
- Testable: Easy dependency injection for testing
- Performance: Fine-grained rebuilds with Selector
- Ecosystem: Excellent documentation and community
✓ Excellent documentation and learning resources
✓ Proven in hundreds of production applications
✓ Works with simple state (ChangeNotifier) and complex (Stream, Future)
✓ Great for team projects with clear patterns
✓ Easy to scale from simple to complex apps
✗ Requires BuildContext in some scenarios
✗ Not compile-safe (runtime discovery)
✗ Can become complex with many interdependent providers
✗ Learning curve for dependency injection concepts
- Medium to large applications
- Shared state across multiple screens
- Teams prioritizing maintainability
- Projects that need to grow incrementally
Riverpod is the next-generation state management solution addressing Provider's limitations while maintaining familiar patterns. It's compile-safe, efficient, and perfect for production applications.
Riverpod introduces game-changing features:
- Compile-Time Safety: Caught at compile time, not runtime
- No BuildContext: Eliminates entire class of bugs
- Powerful Composition: Providers combine seamlessly
- Family Modifiers: Dynamic provider parameterization
- Native Async: First-class support for Future and Stream
// 1. Simple State Provider
final counterProvider = StateProvider<int>((ref) => 0);
// 2. State with Business Logic (StateNotifier)
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void decrement() => state--;
}
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
(ref) => CounterNotifier(),
);
// 3. Async Data Provider (Native async handling!)
final userProvider = FutureProvider.autoDispose<User>((ref) async {
final apiService = ref.watch(apiServiceProvider);
return apiService.fetchUser('123');
});
// 4. Family for Parameterized Providers
final userProvider = FutureProvider.family.autoDispose<User, String>(
(ref, userId) async {
final apiService = ref.watch(apiServiceProvider);
return apiService.fetchUser(userId);
},
);
// 5. Computed Providers (Derived State)
final isUserPremiumProvider = Provider.autoDispose<bool>((ref) {
final user = ref.watch(userProvider);
return user.when(
data: (user) => user.isPremium,
loading: () => false,
error: (_, __) => false,
);
});
// Usage in widgets
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: const Text('Increment'),
),
],
);
}
}
// Async data with automatic loading/error states
class UserScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
data: (user) => UserProfile(user: user),
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (error, stackTrace) => Scaffold(
body: Center(child: Text('Error: $error')),
),
);
}
}Stream Providers for Real-Time Data
// Watch real-time updates from backend
final liveNotificationsProvider = StreamProvider.autoDispose<Notification>((ref) {
return ref.watch(webSocketServiceProvider).notificationStream;
});
class NotificationCenter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(liveNotificationsProvider);
return notifications.when(
data: (notification) => Text('New: ${notification.message}'),
loading: () => const Text('Waiting for notifications...'),
error: (error, _) => Text('Connection error: $error'),
);
}
}Provider Invalidation & Refresh
class UserController extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return user.when(
data: (user) => Column(
children: [
Text(user.name),
ElevatedButton(
onPressed: () {
// Invalidate provider to refetch
ref.refresh(userProvider);
},
child: const Text('Refresh'),
),
],
),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
);
}
}- Type-Safe: Compile-time checking prevents entire classes of bugs
- Powerful: Native async, side effects, computed state
- Modular: Providers compose like building blocks
- Efficient: Advanced caching and invalidation
- Modern: Designed for async-first applications
✓ Compile-time safety (catch bugs before runtime)
✓ No BuildContext needed (eliminates whole categories of bugs)
✓ Native async/Future handling
✓ Family modifiers for dynamic providers
✓ Superior performance for complex apps
✓ Future of Flutter state management
✗ Steeper learning curve than Provider
✗ Smaller ecosystem (but growing rapidly)
✗ Breaking changes in major versions
✗ Requires hooks_riverpod for some features
- Production applications requiring type safety
- Complex async state management
- Teams that prioritize testing
- APIs with data fetching and real-time updates
- New projects starting with best practices
GetX provides a comprehensive solution combining state management, routing, dependency injection, and utilities into one cohesive package. It's perfect for teams prioritizing rapid development.
// Step 1: Define Reactive State with GetX
class CounterController extends GetxController {
// Reactive variables using .obs
final RxInt count = 0.obs;
final RxString message = 'Hello'.obs;
final RxList<String> items = <String>[].obs;
// Reactive getters (computed values)
RxBool get isPositive => count > 0 ? true.obs : false.obs;
// Methods
void increment() => count++;
void updateMessage(String text) => message.value = text;
void addItem(String item) => items.add(item);
// Lifecycle
@override
void onInit() {
super.onInit();
// Initialize when controller is created
}
@override
void onReady() {
super.onReady();
// Called after widget is rendered
}
@override
void onClose() {
super.onClose();
// Clean up resources
}
}
// Step 2: Inject and Use
class CounterScreen extends StatelessWidget {
final controller = Get.put(CounterController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('GetX Counter')),
body: Center(
// Reactive widget - rebuilds automatically
child: Obx(
() => Text('Count: ${controller.count.value}'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.increment,
child: const Icon(Icons.add),
),
);
}
}
// Step 3: Navigation (Built-in!)
Get.to(() => const DetailScreen());
Get.toNamed('/detail');
Get.back();Workers for Side Effects & Reactions
class UserController extends GetxController {
final user = Rxn<User>();
final searchQuery = ''.obs;
@override
void onInit() {
super.onInit();
// React immediately to changes
ever(user, (callback) {
debugPrint('User changed: $callback');
});
// React only once
once(user, (callback) {
debugPrint('User set for the first time: $callback');
});
// Debounced reactions (perfect for search)
debounce(searchQuery, (callback) {
performSearch(callback);
}, time: const Duration(milliseconds: 500));
// Periodic reactions
interval(user, (callback) {
syncUserData();
}, time: const Duration(seconds: 30));
}
void performSearch(String query) {
// Debounced search implementation
}
void syncUserData() {
// Periodic sync implementation
}
}Integrated Service Locator
// Register dependencies
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HomeController>(() => HomeController());
Get.lazyPut<HomeRepository>(() => HomeRepository());
}
}
class AppBinding extends Bindings {
@override
void dependencies() {
Get.put<AuthService>(AuthService());
Get.put<UserService>(UserService());
}
}
// Use in main
void main() {
runApp(
GetMaterialApp(
initialBinding: AppBinding(),
home: const HomeScreen(),
getPages: [
GetPage(
name: '/home',
page: () => const HomeScreen(),
binding: HomeBinding(),
),
],
),
);
}
// Access from anywhere
class UserProfile extends StatelessWidget {
final authService = Get.find<AuthService>();
@override
Widget build(BuildContext context) {
return Text(authService.currentUser?.name ?? 'Guest');
}
}- All-in-One: State management, routing, DI, utilities
- Lightweight: Minimal boilerplate for rapid development
- Reactive: Real-time UI updates with .obs
- Utilities: Built-in dialogs, snackbars, bottom sheets
- Performance: Optimized for mobile applications
✓ All-in-one solution (no need for multiple packages)
✓ Minimal boilerplate code
✓ Fast development iteration
✓ Excellent for rapid prototyping
✓ Strong community in startup/mobile circles
✓ Built-in navigation and utilities
✗ Large package size compared to alternatives
✗ Less testable without careful architecture
✗ Not composable (everything in one package)
✗ Can lead to tight coupling if not careful
✗ Less suitable for enterprise applications
- Rapid application development (MVP, startups)
- Teams wanting unified framework
- Projects requiring routing + state management
- Mobile-first applications
- Rapid prototyping and iteration
BLoC (Business Logic Component) is the most sophisticated pattern, separating business logic from UI through event-driven architecture and streams. It's the gold standard for enterprise applications.
// Step 1: Define Events (User Intents)
abstract class CounterEvent extends Equatable {
const CounterEvent();
}
class IncrementPressed extends CounterEvent {
const IncrementPressed();
@override
List<Object?> get props => [];
}
class DecrementPressed extends CounterEvent {
const DecrementPressed();
@override
List<Object?> get props => [];
}
class ResetPressed extends CounterEvent {
const ResetPressed();
@override
List<Object?> get props => [];
}
// Step 2: Define States (State Snapshots)
abstract class CounterState extends Equatable {
const CounterState();
}
class CounterInitial extends CounterState {
@override
List<Object?> get props => [];
}
class CounterUpdated extends CounterState {
final int count;
const CounterUpdated(this.count);
@override
List<Object?> get props => [count];
}
// Step 3: Implement BLoC (Business Logic)
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterInitial()) {
on<IncrementPressed>(_onIncrementPressed);
on<DecrementPressed>(_onDecrementPressed);
on<ResetPressed>(_onResetPressed);
}
Future<void> _onIncrementPressed(
IncrementPressed event,
Emitter<CounterState> emit,
) async {
if (state is CounterUpdated) {
final current = (state as CounterUpdated).count;
emit(CounterUpdated(current + 1));
} else {
emit(const CounterUpdated(1));
}
}
Future<void> _onDecrementPressed(
DecrementPressed event,
Emitter<CounterState> emit,
) async {
if (state is CounterUpdated) {
final current = (state as CounterUpdated).count;
emit(CounterUpdated(current - 1));
} else {
emit(const CounterUpdated(-1));
}
}
Future<void> _onResetPressed(
ResetPressed event,
Emitter<CounterState> emit,
) async {
emit(const CounterUpdated(0));
}
}
// Step 4: Use in UI (Presentation Layer)
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc(),
child: const CounterView(),
);
}
}
class CounterView extends StatelessWidget {
const CounterView();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('BLoC Counter')),
body: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
if (state is CounterUpdated) {
return Center(child: Text('Count: ${state.count}'));
}
return const Center(child: Text('Count: 0'));
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: 'increment',
onPressed: () {
context.read<CounterBloc>().add(const IncrementPressed());
},
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
heroTag: 'decrement',
onPressed: () {
context.read<CounterBloc>().add(const DecrementPressed());
},
child: const Icon(Icons.remove),
),
const SizedBox(height: 8),
FloatingActionButton(
heroTag: 'reset',
onPressed: () {
context.read<CounterBloc>().add(const ResetPressed());
},
child: const Icon(Icons.refresh),
),
],
),
);
}
}// Repository Abstraction (Data Layer)
abstract class CounterRepository {
Future<int> getInitialCount();
Future<void> saveCount(int value);
}
// Usecase (Domain Layer)
class GetInitialCountUseCase {
final CounterRepository repository;
GetInitialCountUseCase({required this.repository});
Future<int> call() => repository.getInitialCount();
}
class SaveCountUseCase {
final CounterRepository repository;
SaveCountUseCase({required this.repository});
Future<void> call(int count) => repository.saveCount(count);
}
// Enhanced BLoC with Usecases
class CounterBloc extends Bloc<CounterEvent, CounterState> {
final GetInitialCountUseCase getInitialCountUseCase;
final SaveCountUseCase saveCountUseCase;
CounterBloc({
required this.getInitialCountUseCase,
required this.saveCountUseCase,
}) : super(CounterInitial()) {
on<InitializeEvent>(_onInitialize);
on<IncrementPressed>(_onIncrementPressed);
}
Future<void> _onInitialize(
InitializeEvent event,
Emitter<CounterState> emit,
) async {
try {
emit(CounterLoading());
final initialCount = await getInitialCountUseCase();
emit(CounterUpdated(initialCount));
} catch (e) {
emit(CounterError(e.toString()));
}
}
Future<void> _onIncrementPressed(
IncrementPressed event,
Emitter<CounterState> emit,
) async {
if (state is CounterUpdated) {
final currentCount = (state as CounterUpdated).count;
final newCount = currentCount + 1;
try {
await saveCountUseCase(newCount);
emit(CounterUpdated(newCount));
} catch (e) {
emit(CounterError(e.toString()));
}
}
}
}- Testable: Pure functions, observable patterns
- Scalable: Clear separation of concerns (domain/data/presentation)
- Predictable: Event-driven, unidirectional data flow
- Enterprise: Industry standard in large teams
- Flexible: Works with any architecture style
✓ Most testable pattern (business logic isolated)
✓ Excellent for large teams
✓ Clear separation of concerns
✓ Proven in enterprise applications
✓ No BuildContext needed
✓ Industry standard (companies know how to work with it)
✗ Steepest learning curve
✗ Most boilerplate code (Events, States, Bloc)
✗ Largest package footprint
✗ Verbose for simple features
✗ Overhead for small applications
- Large enterprise applications
- Teams with BLoC experience
- High-complexity state management
- Applications requiring extensive testing
- Multi-team projects needing clear standards
| Feature | setState | Provider | Riverpod | GetX | BLoC |
|---|---|---|---|---|---|
| Learning Curve | Trivial | Low | Medium | Low | Steep |
| Boilerplate | Minimal | Moderate | Moderate | Minimal | Extensive |
| Scalability | Poor | High | High | High | Very High |
| Type Safety | Limited | Partial | Excellent | Good | Excellent |
| Testability | Moderate | Good | Excellent | Fair | Excellent |
| Bundle Size | Negligible | ~20KB | ~30KB | ~100KB | ~50KB |
| Performance | Good | Good | Excellent | Good | Good |
| Community | Excellent | Excellent | Growing | Large | Large |
| Async Support | Limited | Good | Native | Good | Good |
| Built-in Routing | No | No | No | Yes | No |
| Time to Production | Minutes | Hours | Hours | Minutes | Days |
| Enterprise Ready | No | Yes | Yes | Maybe | Yes |
Start: Does your state live in one widget only?
├─ YES → Use setState (end)
└─ NO → Next question
Is your application simple (< 5 screens)?
├─ YES → Use Provider or GetX
│ ├─ Prefer GetX if you want routing included
│ └─ Prefer Provider for learning/flexibility
└─ NO → Next question
Do you need type safety and compile-time checks?
├─ YES → Use Riverpod (best for async data)
└─ NO → Next question
Do you need all-in-one solution (state + routing + DI)?
├─ YES → Use GetX
└─ NO → Use Provider or BLoC
Large team + extensive testing required?
├─ YES → Use BLoC
└─ NO → Use Provider
Final Decision:
- Startups/MVPs: GetX
- Growing Companies: Provider → Riverpod
- Enterprises: BLoC
- Type-Safe Focus: Riverpod
Flutter's BuildContext is the glue that holds the widget tree together. Understanding how it works is critical for effective state management.
// Manual InheritedWidget (low-level, rarely used directly)
class ThemeProvider extends InheritedWidget {
final ThemeData theme;
final Function(ThemeData) onThemeChange;
const ThemeProvider({
required this.theme,
required this.onThemeChange,
required super.child,
});
// Access from descendant widgets
static ThemeProvider? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ThemeProvider>();
}
// Whether to notify listeners
@override
bool updateShouldNotify(ThemeProvider oldWidget) {
return oldWidget.theme != theme;
}
}
// Usage
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late ThemeData _theme;
void _changeTheme(ThemeData newTheme) {
setState(() {
_theme = newTheme;
});
}
@override
Widget build(BuildContext context) {
return ThemeProvider(
theme: _theme,
onThemeChange: _changeTheme,
child: const MaterialApp(home: HomeScreen()),
);
}
}
// Access in descendant
class CustomButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final themeProvider = ThemeProvider.of(context);
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: themeProvider?.theme.primaryColor,
),
onPressed: () {},
child: const Text('Press me'),
);
}
}Service Locator is essential for cross-cutting concerns and shared services.
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
// Setup during app initialization
void setupServiceLocator() {
// Singletons (one instance for app lifetime)
getIt.registerSingleton<AppConfig>(AppConfig());
getIt.registerSingleton<AnalyticsService>(AnalyticsService());
// Lazy Singletons (instantiated on first access)
getIt.registerLazySingleton<AuthService>(() => AuthServiceImpl());
getIt.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(apiClient: getIt()),
);
// Factories (new instance every time)
getIt.registerFactory<UserBloc>(
() => UserBloc(repository: getIt<UserRepository>()),
);
// Factories with parameters
getIt.registerFactoryParam<DetailBloc, String, void>(
(id, _) => DetailBloc(id: id, repository: getIt()),
);
}
// Usage
void main() async {
setupServiceLocator();
runApp(const MyApp());
}
class UserScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userBloc = getIt<UserBloc>();
return BlocProvider.value(
value: userBloc,
child: const UserView(),
);
}
}- Use Implicit Injection When Possible: Let Provider/Riverpod manage the dependency graph
- Service Locator for Cross-Cutting Concerns: Analytics, logging, shared preferences
- Avoid Circular Dependencies: Use FutureProvider or AsyncValue to break cycles
- Keep Providers Focused: Single responsibility principle
- Document Your Dependencies: Comment complex provider relationships
- Test with Dependency Overrides: Mock dependencies in tests easily
The most powerful approach for immutable, serializable data models.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isActive,
@Default([]) List<String> roles,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
// Usage
void main() {
// Create
final user = const User(
id: '123',
name: 'John Doe',
email: 'john@example.com',
);
// Serialize to JSON
final json = user.toJson();
print(json); // {id: 123, name: John Doe, ...}
// Deserialize from JSON
final userFromJson = User.fromJson(json);
print(userFromJson.name); // John Doe
// Copy with modifications
final updatedUser = user.copyWith(name: 'Jane Doe');
print(updatedUser.name); // Jane Doe
}Perfect for complex object graphs and offline-first apps.
import 'package:hive/hive.dart';
// Define Hive model
@HiveType(typeId: 0)
class CachedUser extends HiveObject {
@HiveField(0)
late String id;
@HiveField(1)
late String name;
@HiveField(2)
late String email;
@HiveField(3)
late DateTime cachedAt;
}
// Initialize in main
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(CachedUserAdapter());
await Hive.openBox<CachedUser>('users');
runApp(const MyApp());
}
// Use in BLoC with caching layer
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository repository;
UserBloc({required this.repository}) : super(UserInitial()) {
on<FetchUserEvent>(_onFetchUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<UserState> emit,
) async {
try {
emit(UserLoading());
// Try cache first
final box = Hive.box<CachedUser>('users');
final cached = box.get(event.userId);
if (cached != null && DateTime.now().difference(cached.cachedAt).inMinutes < 60) {
// Cache is fresh
emit(UserLoaded(
User(id: cached.id, name: cached.name, email: cached.email),
));
return;
}
// Fetch from remote
final user = await repository.getUser(event.userId);
// Save to cache
await box.put(event.userId, CachedUser()
..id = user.id
..name = user.name
..email = user.email
..cachedAt = DateTime.now());
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
}Lightweight solution for user preferences and simple data.
class PreferencesService {
static const String _userKey = 'user_data';
static const String _themeKey = 'app_theme';
static final _prefs = SharedPreferences.getInstance();
static Future<void> saveUser(User user) async {
final prefs = await _prefs;
final json = jsonEncode(user.toJson());
await prefs.setString(_userKey, json);
}
static Future<User?> getUser() async {
final prefs = await _prefs;
final json = prefs.getString(_userKey);
if (json == null) return null;
return User.fromJson(jsonDecode(json));
}
static Future<void> saveTheme(String theme) async {
final prefs = await _prefs;
await prefs.setString(_themeKey, theme);
}
static Future<String> getTheme() async {
final prefs = await _prefs;
return prefs.getString(_themeKey) ?? 'light';
}
static Future<void> clear() async {
final prefs = await _prefs;
await prefs.clear();
}
}
// Integration with Riverpod
final userPreferencesProvider = FutureProvider<User?>((ref) async {
return PreferencesService.getUser();
});
final themeProvider = FutureProvider<String>((ref) async {
return PreferencesService.getTheme();
});For complex schemas and relational data.
import 'package:drift/drift.dart' as drift;
// Drift table definition
@drift.DataClassName('CachedArticle')
class Articles extends drift.Table {
drift.IntColumn get id => integer().autoIncrement()();
drift.TextColumn get title => text()();
drift.TextColumn get content => text()();
drift.DateTimeColumn get publishedAt => dateTime()();
drift.TextColumn get authorId => text()();
}
@DriftDatabase(tables: [Articles])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
Future<void> insertArticle(CachedArticle article) {
return into(articles).insert(article);
}
Future<List<CachedArticle>> getAllArticles() {
return select(articles).get();
}
Future<void> deleteArticle(int id) {
return delete(articles).delete(articles.id.equals(id) as dynamic);
}
Stream<List<CachedArticle>> watchArticles() {
return select(articles).watch();
}
}
// Using with Riverpod
final databaseProvider = Provider((ref) => AppDatabase());
final articlesProvider = StreamProvider<List<CachedArticle>>((ref) {
final db = ref.watch(databaseProvider);
return db.watchArticles();
});
final articleByIdProvider =
FutureProvider.family<CachedArticle?, int>((ref, id) async {
final db = ref.watch(databaseProvider);
final articles = await db.select(articles).get();
return articles.firstWhereOrNull((a) => a.id == id);
});// TTL-based cache with Riverpod
class CacheEntry<T> {
final T value;
final DateTime createdAt;
final Duration ttl;
CacheEntry({
required this.value,
required this.ttl,
}) : createdAt = DateTime.now();
bool get isExpired => DateTime.now().difference(createdAt) > ttl;
}
final cachedUserProvider = FutureProvider.autoDispose<User>((ref) async {
final cache = ref.watch(cacheManagerProvider);
final repository = ref.watch(userRepositoryProvider);
final cached = cache.get<User>('user_data');
if (cached != null && !cached.isExpired) {
return cached.value;
}
final user = await repository.getUser();
cache.set('user_data', CacheEntry(
value: user,
ttl: const Duration(hours: 1),
));
return user;
});
// Manual invalidation
class UserScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(cachedUserProvider);
return user.when(
data: (user) => Column(
children: [
Text(user.name),
ElevatedButton(
onPressed: () {
// Force refresh
ref.refresh(cachedUserProvider);
},
child: const Text('Refresh'),
),
],
),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
);
}
}import 'package:bloc_test/bloc_test.dart';
import 'package:mockito/mockito.dart';
// Mock the repository
class MockUserRepository extends Mock implements UserRepository {}
void main() {
group('UserBloc', () {
late UserBloc userBloc;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
userBloc = UserBloc(repository: mockRepository);
});
tearDown(() {
userBloc.close();
});
const testUser = User(
id: '1',
name: 'John Doe',
email: 'john@example.com',
);
// Test initial state
test('Initial state is UserInitial', () {
expect(userBloc.state, isA<UserInitial>());
});
// Test successful load
blocTest<UserBloc, UserState>(
'emits [UserLoading, UserLoaded] when FetchUserEvent is added',
setUp: () {
when(mockRepository.getUser('1'))
.thenAnswer((_) async => testUser);
},
build: () => userBloc,
act: (bloc) => bloc.add(const FetchUserEvent('1')),
expect: () => [
isA<UserLoading>(),
isA<UserLoaded>()
.having((state) => state.user, 'user', testUser),
],
);
// Test error handling
blocTest<UserBloc, UserState>(
'emits [UserLoading, UserError] when fetch fails',
setUp: () {
when(mockRepository.getUser('1'))
.thenThrow(Exception('Network error'));
},
build: () => userBloc,
act: (bloc) => bloc.add(const FetchUserEvent('1')),
expect: () => [
isA<UserLoading>(),
isA<UserError>()
.having((state) => state.message, 'message', contains('Network')),
],
);
// Test interaction
test('calls repository.getUser with correct id', () async {
when(mockRepository.getUser('1'))
.thenAnswer((_) async => testUser);
userBloc.add(const FetchUserEvent('1'));
await Future.delayed(const Duration(milliseconds: 100));
verify(mockRepository.getUser('1')).called(1);
});
});
}void main() {
group('UserScreen', () {
testWidgets('displays loading indicator while fetching',
(WidgetTester tester) async {
final mockBloc = MockUserBloc();
whenListen(
mockBloc,
Stream.fromIterable([UserLoading()]),
initialState: UserInitial(),
);
await tester.pumpWidget(
BlocProvider<UserBloc>.value(
value: mockBloc,
child: const MaterialApp(home: UserScreen()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('displays user data when loaded',
(WidgetTester tester) async {
const testUser = User(
id: '1',
name: 'John Doe',
email: 'john@example.com',
);
final mockBloc = MockUserBloc();
whenListen(
mockBloc,
Stream.fromIterable([
UserLoading(),
UserLoaded(testUser),
]),
initialState: UserInitial(),
);
await tester.pumpWidget(
BlocProvider<UserBloc>.value(
value: mockBloc,
child: const MaterialApp(home: UserScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('John Doe'), findsOneWidget);
expect(find.text('john@example.com'), findsOneWidget);
});
testWidgets('shows error message on failure',
(WidgetTester tester) async {
final mockBloc = MockUserBloc();
whenListen(
mockBloc,
Stream.fromIterable([
UserLoading(),
UserError('Failed to load user'),
]),
initialState: UserInitial(),
);
await tester.pumpWidget(
BlocProvider<UserBloc>.value(
value: mockBloc,
child: const MaterialApp(home: UserScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text(contains('Failed to load')), findsOneWidget);
});
});
}void main() {
group('User Provider', () {
test('returns user from repository', () async {
final container = ProviderContainer(
overrides: [
repositoryProvider.overrideWithValue(MockUserRepository()),
],
);
when(container.read(repositoryProvider).getUser('1'))
.thenAnswer((_) async => testUser);
final user = await container.read(userProvider('1').future);
expect(user.name, 'John Doe');
});
testWidgets('displays loading then user data', (WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithValue(MockUserRepository()),
],
child: const MaterialApp(home: UserScreen()),
),
);
// Initially loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Wait for async operation
await tester.pumpAndSettle();
// Now shows data
expect(find.text('John Doe'), findsOneWidget);
});
});
}- Test Business Logic Separately: Keep BLoC tests independent of UI
- Mock External Dependencies: Never make real API calls in tests
- Test State Transitions: Verify all state paths
- Use blocTest for BLoC: Simplifies BLoC testing
- Golden Tests for UI: Use golden files for UI regression detection
- Test Error Scenarios: Don't skip error handling
- Test Interactions: Verify methods called with correct arguments
// Enable Flutter DevTools performance profiling
void main() {
// Log key milestones
Timeline.instantSync('app_launch');
runApp(const MyApp());
// Track frame building time
addPostFrameCallback((_) {
Timeline.instantSync('first_frame_complete');
});
}
// Monitor rebuild counts in debug
class DebugRebuildCounter extends StatefulWidget {
final Widget child;
final String name;
const DebugRebuildCounter({
required this.child,
required this.name,
});
@override
State<DebugRebuildCounter> createState() => _DebugRebuildCounterState();
}
class _DebugRebuildCounterState extends State<DebugRebuildCounter> {
int rebuildCount = 0;
@override
Widget build(BuildContext context) {
rebuildCount++;
if (kDebugMode) {
debugPrint('${widget.name} rebuilt $rebuildCount times');
}
return widget.child;
}
}// BEFORE: Entire widget rebuilds on any provider change
class UserProfile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
final posts = ref.watch(userPostsProvider);
final following = ref.watch(followingProvider);
return Column(
children: [
Text(user.name),
ListView(children: posts),
Text('Following: ${following.length}'),
],
);
}
}
// AFTER: Split into focused widgets
class UserProfile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
const UserNameWidget(),
const UserPostsWidget(),
const FollowingCountWidget(),
],
);
}
}
class UserNameWidget extends ConsumerWidget {
const UserNameWidget();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when user.name changes
final userName = ref.watch(
userProvider.select((user) => user.name),
);
return Text(userName);
}
}
class UserPostsWidget extends ConsumerWidget {
const UserPostsWidget();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when posts change
final posts = ref.watch(userPostsProvider);
return ListView(children: posts);
}
}
class FollowingCountWidget extends ConsumerWidget {
const FollowingCountWidget();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when following list changes
final followingCount = ref.watch(
followingProvider.select((list) => list.length),
);
return Text('Following: $followingCount');
}
}class CacheManager<T> {
final Map<String, (T, DateTime)> _cache = {};
final Duration _ttl;
CacheManager({Duration ttl = const Duration(minutes: 5)}) : _ttl = ttl;
T? get(String key) {
final entry = _cache[key];
if (entry == null) return null;
final isExpired = DateTime.now().difference(entry.$2) > _ttl;
if (isExpired) {
_cache.remove(key);
return null;
}
return entry.$1;
}
void set(String key, T value) {
_cache[key] = (value, DateTime.now());
}
void clear() => _cache.clear();
}
// Usage with Riverpod
final cacheManagerProvider = Provider((ref) => CacheManager<User>());
final cachedUserProvider = FutureProvider.family<User, String>(
(ref, userId) async {
final cache = ref.watch(cacheManagerProvider);
// Check cache
final cached = cache.get(userId);
if (cached != null) return cached;
// Fetch and cache
final repository = ref.watch(repositoryProvider);
final user = await repository.getUser(userId);
cache.set(userId, user);
return user;
},
);class PaginatedNotifier extends StateNotifier<AsyncValue<List<Item>>> {
final Repository repository;
int _currentPage = 0;
PaginatedNotifier(this.repository) : super(const AsyncValue.loading());
Future<void> loadNextPage() async {
state = await AsyncValue.guard(() async {
final currentItems = state.maybeWhen(
data: (items) => items,
orElse: () => [],
);
final newItems = await repository.getItems(page: _currentPage++);
return [...currentItems, ...newItems];
});
}
Future<void> refresh() async {
_currentPage = 0;
state = const AsyncValue.loading();
await loadNextPage();
}
}
final paginatedProvider =
StateNotifierProvider<PaginatedNotifier, AsyncValue<List<Item>>>(
(ref) => PaginatedNotifier(ref.watch(repositoryProvider)),
);
// UI with lazy loading
class ItemsList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemsAsync = ref.watch(paginatedProvider);
return itemsAsync.when(
data: (items) => ListView.builder(
itemCount: items.length + 1,
itemBuilder: (context, index) {
if (index == items.length) {
// Trigger load when reaching end
Future.microtask(
() => ref.read(paginatedProvider.notifier).loadNextPage(),
);
return const CircularProgressIndicator();
}
return ItemTile(item: items[index]);
},
),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
);
}
}class SearchNotifier extends StateNotifier<AsyncValue<List<Result>>> {
final SearchRepository repository;
Timer? _debounceTimer;
SearchNotifier(this.repository) : super(const AsyncValue.data([]));
void search(String query) {
_debounceTimer?.cancel();
if (query.isEmpty) {
state = const AsyncValue.data([]);
return;
}
state = const AsyncValue.loading();
_debounceTimer = Timer(const Duration(milliseconds: 500), () async {
state = await AsyncValue.guard(
() => repository.search(query),
);
});
}
@override
String toString() {
_debounceTimer?.cancel();
return super.toString();
}
}
// UI
class SearchWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchProvider);
return Column(
children: [
TextField(
onChanged: (query) {
ref.read(searchProvider.notifier).search(query);
},
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
),
),
Expanded(
child: results.when(
data: (items) => ListView(
children: items
.map((item) => SearchResultTile(item: item))
.toList(),
),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
),
),
],
);
}
}// Proper cleanup in BLoC
class DataBloc extends Bloc<DataEvent, DataState> {
StreamSubscription? _streamSubscription;
DataBloc() : super(DataInitial()) {
on<FetchDataEvent>(_onFetchData);
}
Future<void> _onFetchData(
FetchDataEvent event,
Emitter<DataState> emit,
) async {
// Cancel previous subscription to prevent memory leak
_streamSubscription?.cancel();
_streamSubscription = repository.dataStream.listen((data) {
add(DataReceivedEvent(data));
});
}
@override
Future<void> close() {
_streamSubscription?.cancel();
return super.close();
}
}
// Proper cleanup in GetX
class DataController extends GetxController {
late StreamSubscription _subscription;
@override
void onInit() {
super.onInit();
// Initialize subscription
_subscription = repository.stream.listen((data) {
// Handle data
});
}
@override
void onClose() {
_subscription.cancel(); // Always clean up!
super.onClose();
}
}lib/
├── core/
│ ├── constants/
│ ├── errors/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── usecases/
│ │ └── usecase.dart
│ └── utils/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ ├── models/
│ │ │ └── repositories/
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ └── usecases/
│ │ └── presentation/
│ │ ├── bloc/
│ │ ├── pages/
│ │ └── widgets/
│ └── posts/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── config/
├── routes/
└── theme/
class ServiceLocator {
static final _getIt = GetIt.instance;
static Future<void> init() async {
// Core
_setupCore();
// Features
_setupAuth();
_setupPosts();
}
static void _setupCore() {
_getIt.registerSingleton<AppConfig>(AppConfig());
_getIt.registerLazySingleton<AnalyticsService>(AnalyticsService());
}
static void _setupAuth() {
// Data layer
_getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(httpClient: _getIt()),
);
// Repository
_getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: _getIt(),
localDataSource: _getIt(),
),
);
// Usecases
_getIt.registerLazySingleton<LoginUseCase>(
() => LoginUseCase(repository: _getIt()),
);
// BLoC
_getIt.registerFactory<AuthBloc>(
() => AuthBloc(loginUseCase: _getIt()),
);
}
static void _setupPosts() {
// Similar structure
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ServiceLocator.init();
runApp(const MyApp());
}abstract class Failure {
final String message;
final StackTrace? stackTrace;
Failure(this.message, [this.stackTrace]);
}
class NetworkFailure extends Failure {
NetworkFailure(super.message, [super.stackTrace]);
}
class ServerFailure extends Failure {
final int statusCode;
ServerFailure(super.message, this.statusCode, [super.stackTrace]);
}
class CacheFailure extends Failure {
CacheFailure(super.message, [super.stackTrace]);
}
// Use Either for functional error handling
typedef ResultFuture<T> = Future<Either<Failure, T>>;
// Repository with error handling
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
final NetworkInfo networkInfo;
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
ResultFuture<User> getUser(String id) async {
try {
if (await networkInfo.isConnected) {
final user = await remoteDataSource.getUser(id);
await localDataSource.cacheUser(user);
return Right(user);
} else {
final cachedUser = await localDataSource.getUser(id);
if (cachedUser != null) {
return Right(cachedUser);
}
return Left(NetworkFailure('No internet connection'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message, e.statusCode));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}
// BLoC handles Either
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserUseCase getUserUseCase;
UserBloc({required this.getUserUseCase}) : super(UserInitial()) {
on<FetchUserEvent>(_onFetchUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<UserState> emit,
) async {
emit(UserLoading());
final result = await getUserUseCase(event.userId);
result.fold(
(failure) => emit(UserError(_mapFailureToMessage(failure))),
(user) => emit(UserLoaded(user)),
);
}
String _mapFailureToMessage(Failure failure) {
if (failure is NetworkFailure) {
return 'Network error. Please check your connection.';
} else if (failure is ServerFailure) {
return 'Server error (${failure.statusCode}). Please try again.';
} else if (failure is CacheFailure) {
return 'Cache error. Please refresh.';
} else {
return 'An unknown error occurred';
}
}
}enum Environment { development, staging, production }
class AppConfig {
static const Environment environment = String.fromEnvironment('ENVIRONMENT')
.contains('prod')
? Environment.production
: Environment.development;
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);
static const String apiKey = String.fromEnvironment('API_KEY');
static const Duration apiTimeout = Duration(seconds: 30);
static bool get isProduction => environment == Environment.production;
static bool get isDevelopment => environment == Environment.development;
}
// Usage
Dio _setupDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConfig.apiTimeout,
receiveTimeout: AppConfig.apiTimeout,
));
if (AppConfig.isDevelopment) {
dio.interceptors.add(LoggingInterceptor());
}
return dio;
}abstract class AnalyticsService {
Future<void> logEvent(String name, {Map<String, dynamic>? params});
Future<void> logPageView(String pageName);
Future<void> logError(String code, String message);
}
class FirebaseAnalyticsService implements AnalyticsService {
final FirebaseAnalytics _analytics;
FirebaseAnalyticsService(this._analytics);
@override
Future<void> logEvent(String name, {Map<String, dynamic>? params}) {
return _analytics.logEvent(name: name, parameters: params);
}
@override
Future<void> logPageView(String pageName) {
return _analytics.logEvent(
name: 'page_view',
parameters: {'page_name': pageName},
);
}
@override
Future<void> logError(String code, String message) {
return _analytics.logEvent(
name: 'error',
parameters: {
'error_code': code,
'error_message': message,
},
);
}
}
// Integration with state management
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserUseCase getUserUseCase;
final AnalyticsService analytics;
UserBloc({
required this.getUserUseCase,
required this.analytics,
}) : super(UserInitial()) {
on<FetchUserEvent>(_onFetchUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<UserState> emit,
) async {
emit(UserLoading());
analytics.logEvent('user_fetch_started', params: {'user_id': event.userId});
try {
final user = await getUserUseCase(event.userId);
analytics.logEvent('user_fetch_success', params: {
'user_id': user.id,
'timestamp': DateTime.now().toIso8601String(),
});
emit(UserLoaded(user));
} catch (e) {
analytics.logError('USER_FETCH_FAILED', e.toString());
emit(UserError(e.toString()));
}
}
}As your application grows:
- MVP:
setStateorGetXfor speed - Growth Phase:
Providerfor maintainability - Scaling:
Riverpodfor type safety - Enterprise:
BLoCfor complexity
Master these principles for any pattern:
- Separate Concerns: Business logic ≠ UI
- Testability First: Design for easy testing
- Performance Matters: Minimize rebuilds
- Error Handling: Plan for failure
- Documentation: Future you will thank you
- Monitoring: Track real-world performance
- Scalability: Think ahead
Is this a throwaway prototype?
├─ Yes → setState or GetX
└─ No → Next question
Single widget state?
├─ Yes → setState
└─ No → Is app < 5 screens?
├─ Yes → Provider or GetX
└─ No → Type safety critical?
├─ Yes → Riverpod
└─ No → Multi-team project?
├─ Yes → BLoC
└─ No → Provider
- Flutter Official State Management Docs
- Provider Documentation
- Riverpod Official Guide
- BLoC Library
- GetX Documentation
Document Version: 2.0
Last Updated: November 2024
Target Audience: Flutter developers (Beginner to Advanced)
Difficulty: Comprehensive (All Levels)