docs/src/content/docs/principles/state.md
Server Box uses Riverpod for page state, asynchronous data, and service dependencies. This page describes the state-management patterns used in the project.
BuildContext dependency: Services and business logic can access providers outside Widgets.┌─────────────────────────────────────────────┐
│ UI layer (Widget) │
│ ConsumerWidget / ConsumerStatefulWidget │
│ ref.watch() / ref.read() │
└─────────────────────────────────────────────┘
↓ subscribe or call
┌─────────────────────────────────────────────┐
│ Provider layer │
│ @riverpod and generated *.g.dart │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Service / Store layer │
│ Business logic and data access │
└─────────────────────────────────────────────┘
Widgets use ref.watch to subscribe to state and rebuild when it changes. They use ref.read to invoke provider or notifier methods.
NotifierProviderA class-based @riverpod declaration generates a NotifierProvider. It is suitable for synchronous state with update methods:
@riverpod
class ThemeNotifier extends _$ThemeNotifier {
@override
ThemeMode build() {
return SettingStore.themeMode;
}
void setTheme(ThemeMode mode) {
state = mode;
SettingStore.themeMode = mode;
}
}
AsyncNotifierProviderUse it for data with loading, success, and error states:
@riverpod
class ServerStatus extends _$ServerStatus {
@override
Future<StatusModel> build(Server server) async {
return fetchStatus(server);
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => fetchStatus(server));
}
}
A Widget should handle every AsyncValue state:
final status = ref.watch(serverStatusProvider(server));
return status.when(
data: (value) => StatusWidget(value),
loading: () => const LoadingWidget(),
error: (error, stack) => ErrorWidget(error),
);
StreamProviderUse it for continuously emitted data:
@riverpod
Stream<CpuUsage> cpuUsage(Ref ref, Server server) {
final client = ref.watch(sshClientProvider(server));
final stream = client.monitorCpu();
ref.onDispose(client.stopMonitoring);
return stream;
}
Register cleanup for clients, timers, and subscriptions with ref.onDispose.
A parameterized provider maintains independent state for each parameter set:
@riverpod
Future<List<Container>> containers(Ref ref, Server server) async {
final client = await ref.watch(sshClientProvider(server).future);
return client.listContainers();
}
containersProvider(server) and containersProvider(server2) represent different server states.
Keep update logic in notifier methods:
ref.read(settingsProvider.notifier).updateTheme(darkMode);
Derive values from existing providers instead of storing another mutable copy:
@riverpod
int totalServers(Ref ref) {
return ref.watch(serversProvider).length;
}
@riverpod
List<Server> onlineServers(Ref ref) {
return ref.watch(serversProvider).where((server) => server.isOnline).toList();
}
The actual per-server provider is serverProvider(serverId). Each instance contains the server configuration, connection state, SSH client, current status, and Monitor agent access information.
final serverState = ref.watch(serverProvider(serverId));
// ServerNotifier owns connection, collection, and error handling.
await ref.read(serverProvider(serverId).notifier).refresh();
Pages read this state through the provider rather than managing connection lifecycles themselves.
A provider that needs periodic refreshes can create a timer and cancel it when disposed:
@riverpod
class AutoRefreshServerStatus extends _$AutoRefreshServerStatus {
Timer? _timer;
@override
Future<StatusModel> build(Server server) async {
_timer = Timer.periodic(const Duration(seconds: 5), (_) => refresh());
ref.onDispose(() => _timer?.cancel());
return fetchStatus(server);
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => fetchStatus(server));
}
}
Use ref.watch for provider dependencies. When an upstream provider changes, Riverpod can recompute the dependent provider:
@riverpod
Future systemInfo(Ref ref, Server server) async {
final client = await ref.watch(sshClientProvider(server).future);
return client.getSystemInfo();
}
The authoritative local store is the encrypted SQLite database store.db:
SqliteStore.final servers = Stores.server.readAll();
Stores.server.put(server);
Stores.server.deleteById(server.id);
Providers manage runtime state. Data that must survive a restart belongs in a store, not only in a provider cache.
@Riverpod(keepAlive: true) only when state must survive across pages.select to subscribe to only the fields a Widget needs.ref.onDispose.@riverpod and code generation.AsyncValue.keepAlive settings and deeply nested provider graphs.