Files
eryao/.trellis/spec/frontend/state-management.md
T

8.1 KiB

State Management

State management patterns in Flutter app.


Overview

This app uses ChangeNotifier + Provider for state management:

  • AuthBloc uses ChangeNotifier for 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);
});