navhost_state 0.1.1
navhost_state: ^0.1.1 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 auto-tracking widgets to Flutter, along with scoped ViewModel management tied to the widget lifecycle.
Features #
.obsreactive values — wrap any value inRx<T>with0.obs,'hello'.obs,false.obsObsauto-tracking widget — rebuilds only when theRxvalues read inside it change- Fine-grained rebuilds — each
Obstracks its own dependencies independently - Scoped ViewModels —
context.viewModel()creates once, reuses across rebuilds, auto-cleans on unmount ViewModelScope— ties ViewModel lifecycle to the widget treerxRoutes()— wraps all navhost routes withViewModelScopeautomaticallyViewModelBuilder— convenience widget that creates + subscribes in one stepListen— subscribes to an existing ViewModel without re-creating it
Getting started #
dependencies:
navhost: ^0.1.0
navhost_state: ^0.1.0
import 'package:navhost/navhost.dart';
import 'package:navhost_state/navhost_state.dart';
Usage #
Reactive values with .obs and Obs #
final count = 0.obs;
// Obs rebuilds automatically when count.value changes
Obs(() => Text('${count.value}'))
// Only notifies when value actually changes
count.value = 5; // triggers rebuild
count.value = 5; // no-op — same value
Multiple Obs watching the same value #
final name = 'World'.obs;
Column(children: [
Obs(() => Text('Hello, ${name.value}!')), // rebuilds on change
Obs(() => Text('Length: ${name.value.length}')), // also rebuilds
])
Each Obs tracks only the Rx values it actually reads — unrelated changes don't cause rebuilds.
ViewModel with .obs #
ViewModels don't need to extend ChangeNotifier. Plain classes work perfectly with .obs + Obs:
class CounterViewModel {
final count = 0.obs;
void increment() => count.value++;
}
ViewModelBuilder and Listen still require ChangeNotifier because they use ListenableBuilder internally. For plain class ViewModels, use context.viewModel() + Obs instead.
Scoped ViewModel with rxRoutes #
rxRoutes() wraps each route with a ViewModelScope, so context.viewModel() just works:
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 navigation stack.
Manual ViewModelScope #
You can use ViewModelScope directly without rxRoutes():
NavRoute(
'/counter',
(_) => ViewModelScope(
child: CounterPage(),
),
)
ViewModelBuilder #
Creates a scoped ViewModel and subscribes to it in one widget:
ViewModelBuilder<CounterViewModel>(
factory: () => CounterViewModel(),
builder: (context, vm, child) => Obs(() =>
Text('Count: ${vm.count.value}'),
),
)
Listen #
Subscribes to an existing ViewModel without re-creating it:
Listen<CounterViewModel>(
builder: (context, vm) => Text('Count: ${vm.count}'),
)
Without navhost — plain StatefulWidget #
The reactive system works independently of navhost. You can use .obs and Obs anywhere:
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}'));
}
}
Integration with other libraries #
navhost_state is unopinionated about dependency injection — the context.viewModel() factory is just a function, so it works naturally with any DI solution.
get_it #
Use get_it to inject services into your ViewModels:
// Register services
final getIt = GetIt.instance;
getIt.registerSingleton<ApiService>(ApiService());
getIt.registerSingleton<AuthRepository>(AuthRepository());
// ViewModel receives dependencies via get_it
class UserViewModel {
final _api = GetIt.I<ApiService>();
final _auth = GetIt.I<AuthRepository>();
final user = Rx<User?>(null);
final loading = false.obs;
Future<void> loadUser(String id) async {
loading.value = true;
user.value = await _api.fetchUser(id, token: _auth.token);
loading.value = false;
}
}
// In your route
final vm = context.viewModel(() => UserViewModel());
return Obs(() => vm.loading.value
? const CircularProgressIndicator()
: Text(vm.user.value?.name ?? 'No user'));
injectable + get_it #
With injectable, use constructor injection for cleaner setup:
@injectable
class OrderViewModel {
final OrderRepository _repo;
final orders = Rx<List<Order>>([]);
OrderViewModel(this._repo);
Future<void> load() async {
orders.value = await _repo.getAll();
}
}
// In your route — get_it resolves the constructor dependencies
final vm = context.viewModel(() => getIt<OrderViewModel>());
return Obs(() => ListView(
children: vm.orders.value.map((o) => Text(o.title)).toList(),
));
dio #
Pair with dio for HTTP-driven state:
class PostsViewModel {
final _dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final posts = Rx<List<Post>>([]);
final error = Rx<String?>(null);
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 #
Persist and restore reactive state with shared_preferences:
class SettingsViewModel {
final darkMode = false.obs;
final locale = 'en'.obs;
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);
}
}
freezed #
Use freezed for immutable models with copyWith — a natural fit with Rx:
@freezed
class UserProfile with _$UserProfile {
const factory UserProfile({
required String name,
required String email,
@Default(false) bool verified,
}) = _UserProfile;
}
class ProfileViewModel {
final profile = Rx<UserProfile?>(null);
final saving = false.obs;
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;
}
}
hive #
Local persistence with hive:
class NotesViewModel {
final notes = Rx<List<Note>>([]);
late final Box<Note> _box;
Future<void> init() async {
_box = await Hive.openBox<Note>('notes');
notes.value = _box.values.toList();
}
Future<void> add(String text) async {
final note = Note(text: text, createdAt: DateTime.now());
await _box.add(note);
notes.value = _box.values.toList();
}
Future<void> delete(int index) async {
await _box.deleteAt(index);
notes.value = _box.values.toList();
}
}
firebase #
Firestore and Auth with cloud_firestore and firebase_auth:
class AuthViewModel {
final user = Rx<User?>(FirebaseAuth.instance.currentUser);
AuthViewModel() {
FirebaseAuth.instance.authStateChanges().listen((u) {
user.value = u;
});
}
Future<void> signIn(String email, String password) async {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
}
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
class ChatViewModel {
final messages = Rx<List<Message>>([]);
ChatViewModel(String chatId) {
FirebaseFirestore.instance
.collection('chats/$chatId/messages')
.orderBy('timestamp')
.snapshots()
.listen((snapshot) {
messages.value = snapshot.docs.map(Message.fromDoc).toList();
});
}
}
web_socket_channel #
Real-time data with web_socket_channel:
class LivePriceViewModel {
final price = 0.0.obs;
final connected = false.obs;
late final WebSocketChannel _channel;
void connect(String symbol) {
_channel = WebSocketChannel.connect(
Uri.parse('wss://stream.example.com/prices/$symbol'),
);
connected.value = true;
_channel.stream.listen(
(data) => price.value = double.parse(data),
onDone: () => connected.value = false,
);
}
void disconnect() {
_channel.sink.close();
connected.value = false;
}
}
flutter_secure_storage #
Secure token storage with flutter_secure_storage:
class SessionViewModel {
final _storage = const FlutterSecureStorage();
final isLoggedIn = false.obs;
final token = Rx<String?>(null);
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;
}
}
Migrating from other state management libraries #
If you're adopting navhost for navigation, you can migrate your state management incrementally — old and new screens coexist without conflict.
From Provider / ChangeNotifierProvider #
Before:
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// Provided at the top of the tree
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: Consumer<CounterModel>(
builder: (context, model, _) => Text('${model.count}'),
),
)
After:
class CounterViewModel {
final count = 0.obs;
void increment() => count.value++;
}
// Scoped to the route automatically via rxRoutes
final vm = context.viewModel(() => CounterViewModel());
return Obs(() => Text('${vm.count.value}'));
During migration — existing Provider screens keep working. New screens use context.viewModel() + Obs. No need to touch the MultiProvider setup until you're ready.
From Riverpod #
Before:
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
(ref) => CounterNotifier(),
);
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
}
// In a ConsumerWidget
final count = ref.watch(counterProvider);
return Text('$count');
After:
class CounterViewModel {
final count = 0.obs;
void increment() => count.value++;
}
final vm = context.viewModel(() => CounterViewModel());
return Obs(() => Text('${vm.count.value}'));
During migration — ProviderScope and ViewModelScope are independent. Screens using ref.watch and screens using Obs can coexist in the same app. Migrate one screen at a time.
From GetX #
Before:
class CounterController extends GetxController {
final count = 0.obs;
void increment() => count++;
}
// Global binding
Get.put(CounterController());
final ctrl = Get.find<CounterController>();
return Obx(() => Text('${ctrl.count}'));
After:
class CounterViewModel {
final count = 0.obs;
void increment() => count.value++;
}
final vm = context.viewModel(() => CounterViewModel());
return Obs(() => Text('${vm.count.value}'));
The API is intentionally similar. Key differences: ViewModels are scoped to the route (not global), and you use .value explicitly instead of operator overloading. No Get.put / Get.find — the widget tree owns the lifecycle.
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;
void increment() => count.value++;
}
final vm = context.viewModel(() => CounterViewModel());
return Obs(() => Text('${vm.count.value}'));
During migration — BlocProvider and ViewModelScope don't interfere. You can wrap new navhost routes with rxRoutes() while existing routes keep their BlocProvider wrappers.
Migration tips #
- Migrate one screen at a time — all approaches coexist in the same widget tree
- Start with new screens — use
context.viewModel()+Obsfor new routes, leave existing ones untouched - Move shared services last — keep your repositories and API clients in get_it or Provider; only migrate the UI-facing state
- Remove old providers once no screen references them — the compiler will tell you when
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