navhost_state 0.2.2
navhost_state: ^0.2.2 copied to clipboard
Reactive state management extensions for navhost — .obs values, Obs auto-tracking widget, ViewModelBuilder, and Listen.
navhost_state #
Reactive state management for navhost.
navhost_state brings GetX-style .obs reactive values and Obs / ObsBuilder auto-tracking widgets to Flutter, along with optional scoped ViewModel management tied to the widget lifecycle.
Table of contents #
- Getting started
- Core concepts
- Recommended pattern: state hoisting
- Usage
- Alternative: scoped ViewModels
- Integration with other libraries
- Migrating from other state management libraries
- Example
- License
Getting started #
dependencies:
navhost: ^latest
navhost_state: ^latest
import 'package:navhost/navhost.dart';
import 'package:navhost_state/navhost_state.dart';
Core concepts #
| Concept | Description |
|---|---|
Rx<T> |
Reactive wrapper around a value. Reading .value inside Obs registers a dependency. |
.obs |
Extension that wraps any value in Rx<T>: 0.obs, 'hello'.obs, false.obs. |
Rx.update(fn) |
Updates value by applying a function to the current value. |
computed(() => ...) |
Read-only Rx derived from other Rx values; updates automatically. |
effect(() { }) |
Runs a callback immediately and re-runs it when tracked Rx values change. |
batch(() { }) |
Defers all Rx notifications until the callback returns — one rebuild instead of many. |
RxList<E> |
Reactive List — notifies on in-place mutations (add, remove, etc.). |
RxMap<K,V> |
Reactive Map — notifies on in-place mutations ([]=, remove, etc.). |
RxSet<E> |
Reactive Set — notifies on in-place mutations (add, remove, etc.). |
Rx.toStream() |
Exposes an Rx as a Stream of future changes. |
fromStream(stream, initial:) |
Creates an Rx that syncs from a Stream. |
Obs |
Widget that rebuilds automatically when any Rx value read inside its builder changes. |
ObsBuilder |
Same as Obs, but the builder receives a BuildContext. |
ViewModel |
Base class with onInit() / onDispose() lifecycle hooks. |
ViewModelScope |
Ties a ViewModel's lifecycle to the widget tree (alternative pattern). |
rxRoutes() |
Wraps navhost routes with ViewModelScope automatically (alternative pattern). |
Recommended pattern: state hoisting #
The simplest and most testable approach: create the ViewModel in the route builder and pass it as a constructor parameter to the page. The page declares its dependencies explicitly — no implicit lookups, no scope infrastructure.
NavController(
routes: [
NavRoute('/counter', (_, _) => CounterPage(
viewModel: CounterViewModel(), // created here, owned here
)),
],
)
The ViewModel is created once when the route is built and lives as long as the page widget. When the route is popped the ViewModel is garbage-collected — no explicit cleanup needed.
Why hoisting?
- Pages are just functions of their inputs — easy to test with a mock ViewModel
- Dependencies are visible at the call site — no magic lookups inside the widget tree
- Works with any DI system — resolve from get_it, pass in, done
- No
ViewModelScopeorrxRoutes()required
ViewModelScope, rxRoutes(), and context.viewModel() remain available for cases where hoisting isn't practical (deeply nested widgets, shared ViewModels across routes). See Alternative: scoped ViewModels.
Usage #
Reactive values with .obs #
final count = 0.obs;
count.value = 5; // triggers rebuild of any Obs reading it
count.value = 5; // no-op — same value, no rebuild
Any Dart type can be made reactive:
final name = 'World'.obs;
final visible = true.obs;
final items = Rx<List<String>>([]);
final user = Rx<User?>(null);
Use update to derive a new value from the current one:
count.update((prev) => prev + 1);
items.update((prev) => [...prev, 'new item']);
update reads the current value without registering a tracking dependency, then assigns the result through the normal setter (equality-checked, notifies subscribers only on change).
Obs and ObsBuilder #
Obs takes a positional builder with no context:
Obs(() => Text('${count.value}'))
ObsBuilder takes a named builder with BuildContext — use it when you need the theme, a navigator, or a dialog:
ObsBuilder(
builder: (context) => Text(
'${count.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
)
Both track only the Rx values actually read during the build — unrelated changes don't cause a rebuild.
batch #
Defer all notifications from multiple Rx changes and flush them together, causing a single rebuild instead of one per change:
batch(() {
_firstName.value = 'Jane';
_lastName.value = 'Doe';
_age.value = 30;
}); // one rebuild
Nested batch calls are safe — the flush happens only when the outermost batch returns.
computed #
A read-only Rx whose value is derived from other Rx values and updates automatically when any dependency changes:
final firstName = 'Jane'.obs;
final lastName = 'Doe'.obs;
final fullName = computed(() => '${firstName.value} ${lastName.value}');
print(fullName.value); // Jane Doe
firstName.value = 'John';
print(fullName.value); // John Doe — updated automatically
Assigning to a computed throws UnsupportedError. Update its dependencies instead.
effect #
Runs a callback immediately and re-runs it whenever any Rx value read inside it changes. Returns an Effect handle — call dispose() to stop:
final name = 'Alice'.obs;
final e = effect(() => print('Hello, ${name.value}'));
// prints: Hello, Alice
name.value = 'Bob';
// prints: Hello, Bob
e.dispose(); // stops reacting
Reactive collections #
RxList, RxMap, and RxSet notify Obs on in-place mutations without requiring a full value reassignment:
final items = RxList<String>();
final scores = RxMap<String, int>();
final tags = RxSet<String>();
items.add('hello'); // triggers rebuild
scores['Alice'] = 10; // triggers rebuild
tags.add('flutter'); // triggers rebuild
Read via .value (returns an unmodifiable view) or the built-in query methods — all tracked:
Obs(() => Column(children: [
Text('${items.length} items'),
Text(items.sortedBy((e) => e).joinToString()),
Text(scores.filterValues((v) => v > 5).keys.join(', ')),
]))
See collection extensions for the full query API.
Rx.toStream / fromStream #
Interop with stream-based APIs:
// Stream of future changes (does not emit current value on listen)
final sub = count.toStream().listen(print);
count.value = 1; // prints 1 synchronously
// Rx backed by a Stream
final rx = fromStream(priceStream, initial: 0.0);
Obs(() => Text('${rx.value}'))
ViewModel lifecycle #
Extend ViewModel to get onInit and onDispose hooks that fire automatically inside ViewModelScope:
class PostListViewModel extends ViewModel {
final _posts = RxList<Post>();
List<Post> get posts => _posts.value;
@override
void onInit() => loadPosts();
@override
void onDispose() => _subscription?.cancel();
}
Works with state hoisting (call manually) or with rxRoutes() / ViewModelScope (called automatically).
Collection extensions #
navhost_state exports Kotlin-inspired extensions on plain Dart collections (via package:collection + additions). These work on any Iterable, List, Map — not just Rx types:
Iterable<E> — singleOrNull, filterNot, groupBy, chunked, count, sumOf, minByOrNull, maxByOrNull, associateBy, associate, partition, distinctBy, flatMap, zip, joinToString, onEach, drop, dropWhile
List<E> — sortedByDescending, indices (plus sortedBy, forEachIndexed, mapIndexed, whereNot, slices from package:collection)
Map<K,V> — mapValues, mapKeys, filterKeys, filterValues, filter, getOrDefault, getOrElse, none, all, count, minByOrNull, maxByOrNull, toList
All query methods on RxList, RxMap, and RxSet are tracked — reading them inside Obs registers a dependency.
ViewModel with private Rx #
Keep Rx fields private and expose plain values through getters. Callers get a clean API with no .value noise, while Obs still tracks changes because the getter reads .value internally:
class CounterViewModel {
final _count = 0.obs;
int get count => _count.value; // public value, private Rx
void increment() => _count.value++;
void reset() => _count.value = 0;
}
class CounterPage extends StatelessWidget {
final CounterViewModel viewModel;
const CounterPage({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
return Obs(() => Column(
children: [
Text('${viewModel.count}'), // no .value at the call site
FilledButton(onPressed: viewModel.increment, child: const Text('+')),
],
));
}
}
Mutation is only possible through the ViewModel's own methods — the Rx cannot be replaced or observed directly from outside.
Complex state example #
A realistic ViewModel with multiple reactive fields, async loading, error handling, and derived state:
class PostListViewModel {
final PostRepository _repo;
PostListViewModel(this._repo);
final _posts = Rx<List<Post>>([]);
final _isLoadingPosts = false.obs;
final _postsError = Rx<String?>(null);
final _searchQuery = ''.obs;
List<Post> get posts => _posts.value;
bool get isLoadingPosts => _isLoadingPosts.value;
String? get postsError => _postsError.value;
String get searchQuery => _searchQuery.value;
// Derived state — tracked by Obs because it reads reactive getters
List<Post> get filteredPosts => searchQuery.isEmpty
? posts
: posts.where((p) => p.title.toLowerCase().contains(searchQuery.toLowerCase())).toList();
bool get isPostsEmpty => !isLoadingPosts && postsError == null && filteredPosts.isEmpty;
Future<void> loadPosts() async {
_isLoadingPosts.value = true;
_postsError.value = null;
try {
_posts.value = await _repo.fetchPosts();
} catch (e) {
_postsError.value = e.toString();
} finally {
_isLoadingPosts.value = false;
}
}
void searchPosts(String q) => _searchQuery.value = q;
}
The page wraps its body in a single Obs — the standard approach when a ViewModel owns a whole page:
class PostListPage extends StatefulWidget {
final PostListViewModel viewModel;
const PostListPage({super.key, required this.viewModel});
@override
State<PostListPage> createState() => _PostListPageState();
}
class _PostListPageState extends State<PostListPage> {
@override
void initState() {
super.initState();
widget.viewModel.loadPosts();
}
@override
Widget build(BuildContext context) {
final vm = widget.viewModel;
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: Obs(() => Column(
children: [
TextField(
onChanged: vm.searchPosts,
decoration: InputDecoration(
hintText: 'Search...',
suffixIcon: vm.searchQuery.isNotEmpty
? IconButton(icon: const Icon(Icons.clear), onPressed: () => vm.searchPosts(''))
: null,
),
),
Expanded(child: switch (true) {
_ when vm.isLoadingPosts => const Center(child: CircularProgressIndicator()),
_ when vm.postsError != null => Center(child: Text(vm.postsError!)),
_ when vm.isPostsEmpty => const Center(child: Text('No posts found.')),
_ => ListView.builder(
itemCount: vm.filteredPosts.length,
itemBuilder: (_, i) => ListTile(
title: Text(vm.filteredPosts[i].title),
subtitle: Text(vm.filteredPosts[i].body, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
}),
],
)),
);
}
}
Wired up via state hoisting:
NavRoute('/posts', (_, _) => PostListPage(
viewModel: PostListViewModel(getIt<PostRepository>()),
)),
Without navhost #
The reactive system works independently of navhost — use .obs and Obs in any widget:
class CounterPage extends StatefulWidget {
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
final _count = 0.obs;
@override
Widget build(BuildContext context) {
return Obs(() => Text('${_count.value}'));
}
}
Alternative: scoped ViewModels #
When state hoisting isn't practical — deeply nested widgets, shared state across sibling routes — use the scoped ViewModel system instead.
rxRoutes and context.viewModel #
rxRoutes() wraps each route with a ViewModelScope, enabling context.viewModel():
final nav = NavController(
routes: rxRoutes([
NavRoute('/', (_, _) => const CounterPage()),
NavRoute('/detail/:id', (p, _) => DetailPage(id: p['id']!)),
]),
);
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final vm = context.viewModel(() => CounterViewModel());
return Obs(() => Column(
children: [
Text('Count: ${vm.count.value}'),
FilledButton(onPressed: vm.increment, child: const Text('Increment')),
],
));
}
}
The ViewModel is created once and reused across rebuilds. It is disposed when the route is removed from the stack.
Manual ViewModelScope #
Use ViewModelScope directly without rxRoutes():
NavRoute('/counter', (_, _) => ViewModelScope(child: CounterPage()))
ViewModelBuilder #
Creates a scoped ViewModel and subscribes to it in one widget. Requires ChangeNotifier:
ViewModelBuilder<CounterViewModel>(
factory: () => CounterViewModel(),
builder: (context, vm, child) => Text('Count: ${vm.count}'),
)
Listen #
Subscribes to an existing scoped ViewModel without re-creating it. Requires ChangeNotifier:
Listen<CounterViewModel>(
builder: (context, vm) => Text('Count: ${vm.count}'),
)
Integration with other libraries #
navhost_state is unopinionated about dependency injection. The examples below use public Rx fields for brevity — in production code prefer private fields with public getters as shown in ViewModel with private Rx.
get_it #
class UserViewModel {
final _api = GetIt.I<ApiService>();
final _auth = GetIt.I<AuthRepository>();
final _user = Rx<User?>(null);
final _loading = false.obs;
User? get user => _user.value;
bool get loading => _loading.value;
Future<void> loadUser(String id) async {
_loading.value = true;
_user.value = await _api.fetchUser(id, token: _auth.token);
_loading.value = false;
}
}
// Hoisted into the route
NavRoute('/user/:id', (p, _) => UserPage(
viewModel: UserViewModel(),
))
dio #
class PostsViewModel {
final _dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final _posts = Rx<List<Post>>([]);
final _error = Rx<String?>(null);
List<Post> get posts => _posts.value;
String? get error => _error.value;
Future<void> fetch() async {
try {
final response = await _dio.get('/posts');
_posts.value = (response.data as List).map(Post.fromJson).toList();
} on DioException catch (e) {
_error.value = e.message;
}
}
}
shared_preferences #
class SettingsViewModel {
final _darkMode = false.obs;
final _locale = 'en'.obs;
bool get darkMode => _darkMode.value;
String get locale => _locale.value;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_darkMode.value = prefs.getBool('darkMode') ?? false;
_locale.value = prefs.getString('locale') ?? 'en';
}
Future<void> toggleDarkMode() async {
_darkMode.value = !_darkMode.value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('darkMode', _darkMode.value);
}
}
flutter_secure_storage #
class SessionViewModel {
final _storage = const FlutterSecureStorage();
final _isLoggedIn = false.obs;
final _token = Rx<String?>(null);
bool get isLoggedIn => _isLoggedIn.value;
String? get token => _token.value;
Future<void> restore() async {
_token.value = await _storage.read(key: 'auth_token');
_isLoggedIn.value = _token.value != null;
}
Future<void> login(String newToken) async {
await _storage.write(key: 'auth_token', value: newToken);
_token.value = newToken;
_isLoggedIn.value = true;
}
Future<void> logout() async {
await _storage.delete(key: 'auth_token');
_token.value = null;
_isLoggedIn.value = false;
}
}
firebase #
class AuthViewModel {
final _user = Rx<User?>(FirebaseAuth.instance.currentUser);
User? get user => _user.value;
AuthViewModel() {
FirebaseAuth.instance.authStateChanges().listen((u) => _user.value = u);
}
Future<void> signIn(String email, String password) =>
FirebaseAuth.instance.signInWithEmailAndPassword(email: email, password: password);
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
freezed #
class ProfileViewModel {
final _profile = Rx<UserProfile?>(null);
final _saving = false.obs;
UserProfile? get profile => _profile.value;
bool get saving => _saving.value;
void updateName(String name) =>
_profile.value = _profile.value?.copyWith(name: name);
Future<void> save() async {
_saving.value = true;
await api.updateProfile(_profile.value!);
_saving.value = false;
}
}
Migrating from other state management libraries #
All approaches coexist in the same widget tree — migrate one screen at a time.
From Provider / ChangeNotifierProvider #
// Before
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() { _count++; notifyListeners(); }
}
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: Consumer<CounterModel>(
builder: (context, model, _) => Text('${model.count}'),
),
)
// After
class CounterViewModel {
final _count = 0.obs;
int get count => _count.value;
void increment() => _count.value++;
}
// Hoisted into the route — no Provider needed
NavRoute('/', (_, _) => CounterPage(viewModel: CounterViewModel()))
From Riverpod #
// Before
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
(ref) => CounterNotifier(),
);
// In a ConsumerWidget
final count = ref.watch(counterProvider);
return Text('$count');
// After
class CounterViewModel {
final _count = 0.obs;
int get count => _count.value;
void increment() => _count.value++;
}
NavRoute('/', (_, _) => CounterPage(viewModel: CounterViewModel()))
ProviderScope and navhost_state coexist — screens using ref.watch and screens using Obs can live in the same app.
From GetX #
// Before
class CounterController extends GetxController {
final count = 0.obs;
void increment() => count++;
}
Get.put(CounterController());
return Obx(() => Text('${Get.find<CounterController>().count}'));
// After
class CounterViewModel {
final _count = 0.obs;
int get count => _count.value;
void increment() => _count.value++;
}
NavRoute('/', (_, _) => CounterPage(viewModel: CounterViewModel()))
Key differences from GetX: ViewModels are scoped to the route (not global), .value is explicit, and there is no Get.put / Get.find.
From Bloc / Cubit #
// Before
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
BlocProvider(
create: (_) => CounterCubit(),
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
),
)
// After
class CounterViewModel {
final _count = 0.obs;
int get count => _count.value;
void increment() => _count.value++;
}
NavRoute('/', (_, _) => CounterPage(viewModel: CounterViewModel()))
BlocProvider and navhost_state don't interfere — existing routes keep their BlocProvider wrappers while new routes use hoisting.
Example #
See the example app for demos of all features: reactive counter, ViewModelBuilder, multiple observers with a color picker, and a todo list.
License #
MIT