Skip to content

State Management

DWallet uses Riverpod for state management, providing type-safe, testable, and reactive state handling throughout the app.

Riverpod offers several advantages:

  • Type-safe: Catch errors at compile time
  • Testable: Easy to mock and test in isolation
  • Reactive: Automatic UI updates when state changes
  • Composable: Providers can depend on other providers
  • DevTools: Excellent debugging support

For async operations like API calls or initialization that requires async work.

class AuthUserService extends AsyncNotifier<User> {
@override
Future<User> build() async {
// Async initialization
final prefs = await SharedPreferences.getInstance();
return _loadUser(prefs);
}
Future<void> updateUser(User newUser) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _saveUser(newUser);
return newUser;
});
}
}

Usage:

final userAsync = ref.watch(authUserServiceProvider);
userAsync.when(
data: (user) => Text(user.fullName),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);

For synchronous mutable state that needs methods to update it.

class ThemeService extends Notifier<ThemeModel> {
@override
ThemeModel build() {
final storage = ref.read(sharedPrefsProvider);
return _loadTheme(storage);
}
void setThemeMode(ThemeMode mode) {
state = state.copyWith(mode: mode);
_persistTheme();
}
void setColorScheme(AppColorScheme scheme) {
state = state.copyWith(colorScheme: scheme);
_persistTheme();
}
}

Usage:

final theme = ref.watch(themeServiceProvider);
// In a button:
ref.read(themeServiceProvider.notifier).setThemeMode(ThemeMode.dark);

For computed values or simple immutable state.

// Computed provider
final userCurrencyProvider = Provider<Currency>((ref) {
final userAsync = ref.watch(authUserServiceProvider);
return userAsync.value?.currency ?? CurrencyMock.usd;
});
// Simple provider
final showLockScreenProvider = Provider<bool>((ref) {
final securityState = ref.watch(securityProvider);
return securityState.isAppLockEnabled && securityState.isLocked;
});

Services are typically declared as providers in the same file:

// In _theme_service.dart
class ThemeService extends Notifier<ThemeModel> {
// ... implementation
}
final themeServiceProvider = NotifierProvider<ThemeService, ThemeModel>(
ThemeService.new,
);

Providers can read other providers using ref.read() or ref.watch():

class SecurityNotifier extends Notifier<SecurityState> {
@override
SecurityState build() {
// Read other providers in build()
final storage = ref.read(securityStorageServiceProvider);
return _loadState(storage);
}
void someMethod() {
// Read providers in methods
final storage = ref.read(securityStorageServiceProvider);
storage.savePin('1234');
}
}

Always use immutable state objects with copyWith():

class SecurityState {
final bool isLocked;
final bool hasPin;
const SecurityState({
this.isLocked = false,
this.hasPin = false,
});
SecurityState copyWith({
bool? isLocked,
bool? hasPin,
}) {
return SecurityState(
isLocked: isLocked ?? this.isLocked,
hasPin: hasPin ?? this.hasPin,
);
}
}

For widgets that need to read providers:

class HomeView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeServiceProvider);
return Scaffold(
body: Text('Theme: ${theme.mode}'),
);
}
}

For widgets that need both state and providers:

class AppLockScreen extends ConsumerStatefulWidget {
@override
ConsumerState<AppLockScreen> createState() => _AppLockScreenState();
}
class _AppLockScreenState extends ConsumerState<AppLockScreen> {
@override
Widget build(BuildContext context) {
final lockoutState = ref.watch(lockoutProvider);
return Text('Attempts: ${lockoutState.failedAttempts}');
}
}

Watch only specific properties to minimize rebuilds:

// Bad: Rebuilds when any theme property changes
final theme = ref.watch(themeServiceProvider);
Text(theme.mode.toString());
// Good: Only rebuilds when mode changes
final mode = ref.watch(
themeServiceProvider.select((t) => t.mode)
);
Text(mode.toString());
void main() {
test('SecurityNotifier verifies PIN correctly', () {
final container = ProviderContainer();
final notifier = container.read(securityProvider.notifier);
notifier.setPin('1234');
expect(notifier.verifyPin('1234'), true);
expect(notifier.verifyPin('0000'), false);
});
}
  1. Use ref.watch() in build methods for reactive UI updates
  2. Use ref.read() in callbacks (onPressed, initState, etc.)
  3. Keep state immutable with copyWith() pattern
  4. Separate concerns - one provider per feature/area
  5. Use select for granular rebuilds
  6. Dispose resources in provider’s dispose() method if needed