8.1 KiB
8.1 KiB
State Management
State management patterns in Flutter app.
Overview
This app uses ChangeNotifier + Provider for state management:
- AuthBloc uses
ChangeNotifierfor auth state - State classes are immutable value objects
- Repository pattern separates data access from business logic
- DI via provider/factory pattern
State Management Pattern
Bloc/Cubit Pattern
Use ChangeNotifier for complex state:
// features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends ChangeNotifier {
AuthBloc({required AuthRepository repository}) : _repository = repository;
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
AuthState _state = AuthState.initial;
AuthState get state => _state;
Future<void> loginWithOtp({
required String email,
required String otp,
}) async {
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
_state = AuthState(status: AuthStatus.authenticated, user: user);
notifyListeners();
}
}
Immutable State
State classes should be immutable:
// features/auth/presentation/bloc/auth_state.dart
class AuthState {
const AuthState({
required this.status,
this.user,
this.errorMessage,
});
final AuthStatus status;
final AuthUser? user;
final String? errorMessage;
factory AuthState.initial() => const AuthState(status: AuthStatus.initial);
AuthState copyWith({
AuthStatus? status,
AuthUser? user,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
enum AuthStatus {
initial,
loading,
authenticated,
unauthenticated,
}
Repository Pattern
Repository Interface
Define interface in presentation layer:
// features/auth/data/repositories/auth_repository.dart
abstract class AuthRepository {
Future<AuthUser?> recoverSession();
Future<void> sendOtp(String email);
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
});
Future<void> logout();
Future<void> clearLocalSession();
}
Repository Implementation
Implement with data sources:
class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl({
required AuthApi api,
required SessionStore sessionStore,
}) : _api = api,
_sessionStore = sessionStore;
final AuthApi _api;
final SessionStore _sessionStore;
@override
Future<AuthUser?> recoverSession() async {
final session = await _sessionStore.load();
if (session == null) return null;
try {
return await _api.getCurrentUser(session.accessToken);
} catch (e) {
await clearLocalSession();
return null;
}
}
}
Error Handling in State
Try-Catch with Logging
Every async operation should handle errors:
Future<void> start() async {
_state = _state.copyWith(status: AuthStatus.loading);
notifyListeners();
try {
final user = await _repository.recoverSession();
if (user == null) {
_state = const AuthState(status: AuthStatus.unauthenticated);
} else {
_state = AuthState(status: AuthStatus.authenticated, user: user);
}
notifyListeners();
} catch (error, stackTrace) {
_logger.error(
message: 'Session recovery failed: ${error.runtimeType}',
error: error,
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
Global Error Handling
401 Session Invalidation:
// Global callback for 401 errors
Future<void> handleUnauthorized401() async {
if (_handlingUnauthorized) return;
_handlingUnauthorized = true;
try {
await _repository.clearLocalSession();
_logger.warning(message: 'Session invalidated by 401 callback');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
} finally {
_handlingUnauthorized = false;
}
}
DI Pattern
Factory Registration
Register repositories and blocs:
// app/di/di.dart
class ServiceLocator {
static late AuthRepository authRepository;
static late AuthBloc authBloc;
static void setup() {
authRepository = AuthRepositoryImpl(
api: AuthApiImpl(),
sessionStore: SessionStore(),
);
authBloc = AuthBloc(repository: authRepository);
}
}
Widget Access
Access via Provider or global:
// Using global reference
final authBloc = ServiceLocator.authBloc;
// In widget
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: authBloc,
builder: (context, child) {
if (authBloc.state.status == AuthStatus.loading) {
return AppLoadingIndicator();
}
return LoginForm();
},
);
}
}
Common Mistakes
❌ Mutable State
// WRONG: Mutating state directly
class AuthBloc extends ChangeNotifier {
AuthUser? user;
void login() {
user = fetchUser(); // Direct mutation
notifyListeners();
}
}
Right: Immutable state with copyWith:
class AuthBloc extends ChangeNotifier {
AuthState _state = AuthState.initial;
AuthState get state => _state;
void login() async {
final user = await fetchUser();
_state = _state.copyWith(user: user, status: AuthStatus.authenticated);
notifyListeners();
}
}
❌ Importing Feature Data Layer from Other Features
// WRONG: Direct import from another feature
import 'package:app/features/auth/data/repositories/auth_repository.dart';
Right: Access via app-level facade or DI:
final authRepository = ServiceLocator.authRepository;
❌ Skipping Error Logging
// WRONG: No logging
try {
await repository.operation();
} catch (e) {
state = AuthState(status: AuthStatus.error);
}
Right: Log before state change:
try {
await repository.operation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
state = AuthState(status: AuthStatus.error);
}
❌ Creating Per-Widget State Instances
// WRONG: New instance per widget
class MyWidget extends StatelessWidget {
final authBloc = AuthBloc(); // New instance every build
}
Right: Use DI singleton:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authBloc = ServiceLocator.authBloc; // Singleton
// ...
}
}
Module Naming Convention
Logger module path:
| Feature | Module Path |
|---|---|
| auth | features.auth |
| home | features.home |
| divination | features.divination |
| settings | features.settings |
class AuthBloc extends ChangeNotifier {
final Logger _logger = getLogger('features.auth.bloc');
// ...
}
Testing State
Unit Testing Bloc
test('AuthBloc login success', () async {
final mockRepo = MockAuthRepository();
final bloc = AuthBloc(repository: mockRepo);
when(mockRepo.loginWithEmailOtp(
email: 'test@example.com',
otp: '123456',
)).thenAnswer((_) async => AuthUser(id: '123', email: 'test@example.com'));
await bloc.loginWithOtp(email: 'test@example.com', otp: '123456');
expect(bloc.state.status, AuthStatus.authenticated);
expect(bloc.state.user?.id, '123');
});
Integration Testing
testWidgets('Login screen shows error on failed login', (tester) async {
await tester.pumpWidget(MyApp());
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.enterText(find.byKey(Key('otp_field')), 'wrong');
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
expect(find.text('Request failed, please try again'), findsOneWidget);
});