Skip to content

State Management

Understanding the state management architecture in Flutter Server Box.

Key Benefits:

  • Compile-time safety: Catch errors at compile time
  • No BuildContext needed: Access state anywhere
  • Easy testing: Simple to test providers in isolation
  • Code generation: Less boilerplate, type-safe
┌─────────────────────────────────────────────┐
│ UI Layer (Widgets) │
│ - ConsumerWidget / ConsumerStatefulWidget │
│ - ref.watch() / ref.read() │
└─────────────────────────────────────────────┘
↓ watches
┌─────────────────────────────────────────────┐
│ Provider Layer │
│ - @riverpod annotations │
│ - Generated *.g.dart files │
└─────────────────────────────────────────────┘
↓ uses
┌─────────────────────────────────────────────┐
│ Service / Store Layer │
│ - Business logic │
│ - Data access │
└─────────────────────────────────────────────┘

For simple, observable state:

@riverpod
class ThemeNotifier extends _$ThemeNotifier {
@override
ThemeMode build() {
// Load from settings
return SettingStore.themeMode;
}
void setTheme(ThemeMode mode) {
state = mode;
SettingStore.themeMode = mode; // Persist
}
}

Usage:

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

For data that loads asynchronously:

@riverpod
class ServerStatus extends _$ServerStatus {
@override
Future<StatusModel> build(Server server) async {
// Initial load
return await fetchStatus(server);
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await fetchStatus(server);
});
}
}

Usage:

final status = ref.watch(serverStatusProvider(server));
status.when(
data: (data) => StatusWidget(data),
loading: () => LoadingWidget(),
error: (error, stack) => ErrorWidget(error),
)

For continuous data streams:

@riverpod
Stream<CpuUsage> cpuUsage(CpuUsageRef ref, Server server) {
final client = ref.watch(sshClientProvider(server));
final stream = client.monitorCpu();
// Auto-dispose when not watched
ref.onDispose(() {
client.stopMonitoring();
});
return stream;
}

Usage:

final cpu = ref.watch(cpuUsageProvider(server));
cpu.when(
data: (usage) => CpuChart(usage),
loading: () => CircularProgressIndicator(),
error: (error, stack) => ErrorWidget(error),
)

Providers that accept parameters:

@riverpod
Future<List<Container>> containers(ContainersRef ref, Server server) async {
final client = await ref.watch(sshClientProvider(server).future);
return await client.listContainers();
}

Usage:

final containers = ref.watch(containersProvider(server));
// Different servers = different cached states
final containers2 = ref.watch(containersProvider(server2));
ref.read(settingsProvider.notifier).updateTheme(darkMode);
@riverpod
int totalServers(TotalServersRef ref) {
final servers = ref.watch(serversProvider);
return servers.length;
}
@riverpod
List<Server> onlineServers(OnlineServersRef ref) {
final all = ref.watch(serversProvider);
return all.where((s) => s.isOnline).toList();
}

Each server has isolated state:

@riverpod
class ServerProvider extends _$ServerProvider {
@override
ServerState build(Server server) {
return ServerState.disconnected();
}
Future<void> connect() async {
state = ServerState.connecting();
try {
final client = await genClient(server.spi);
state = ServerState.connected(client);
} catch (e) {
state = ServerState.error(e.toString());
}
}
}
// Unique provider per server
@riverpod
ServerStatus serverStatus(ServerStatusRef ref, Server server) {
// server.id used as key
}
@riverpod
class AutoRefreshServerStatus extends _$AutoRefreshServerStatus {
Timer? _timer;
@override
Future<StatusModel> build(Server server) async {
// Start timer
_timer = Timer.periodic(Duration(seconds: 5), (_) {
refresh();
});
ref.onDispose(() {
_timer?.cancel();
});
return await fetchStatus(server);
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => fetchStatus(server));
}
}
@riverpod
Future<SystemInfo> systemInfo(SystemInfoRef ref, Server server) async {
// Wait for SSH client first
final client = await ref.watch(sshClientProvider(server).future);
// Then fetch system info
return await client.getSystemInfo();
}
@riverpod
class ServerStoreNotifier extends _$ServerStoreNotifier {
@override
List<Server> build() {
// Load from Hive
return Hive.box<Server>('servers').values.toList();
}
void addServer(Server server) {
state = [...state, server];
// Persist to Hive
Hive.box<Server>('servers').put(server.id, server);
}
void removeServer(String id) {
state = state.where((s) => s.id != id).toList();
// Remove from Hive
Hive.box<Server>('servers').delete(id);
}
}
@riverpod
class ConnectionManager extends _$ConnectionManager {
@override
ConnectionState build() {
return ConnectionState.idle();
}
Future<void> connect(Server server) async {
state = ConnectionState.connecting();
try {
final client = await genClient(server.spi);
state = ConnectionState.connected(client);
} on SocketException catch (e) {
state = ConnectionState.error('Network error: $e');
} on AuthenticationException catch (e) {
state = ConnectionState.error('Auth failed: $e');
} catch (e) {
state = ConnectionState.error('Unknown error: $e');
}
}
}
@riverpod
class ResilientFetcher extends _$ResilientFetcher {
int _retryCount = 0;
@override
Future<Data> build(Server server) async {
return await _fetchWithRetry();
}
Future<Data> _fetchWithRetry() async {
try {
return await fetchData(server);
} catch (e) {
if (_retryCount < 3) {
_retryCount++;
await Future.delayed(Duration(seconds: 2));
return await _fetchWithRetry();
}
rethrow;
}
}
}
@Riverpod(keepAlive: true) // Don't dispose when no listeners
class GlobalSettings extends _$GlobalSettings {
@override
Settings build() {
return Settings.defaults();
}
}
// Watch only specific part of state
final name = ref.watch(serverProvider.select((s) => s.name));

Family providers cache results per parameter:

// Cached per server ID
final status1 = ref.watch(serverStatusProvider(server1));
final status2 = ref.watch(serverStatusProvider(server2));
// Different states, both cached
test('fetch server status', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
// Override provider
container.overrideFactory(
sshClientProvider,
(ref, server) => MockSshClient(),
);
final status = await container.read(
serverStatusProvider(testServer).future,
);
expect(status, isA<StatusModel>());
});
  1. Co-locate providers: Place near consuming widgets
  2. Use code generation: Always use @riverpod
  3. Keep providers focused: Single responsibility
  4. Handle loading states: Always handle AsyncValue states
  5. Dispose resources: Use ref.onDispose() for cleanup
  6. Avoid deep provider trees: Keep provider graph flat