zenify 2.1.1
zenify: ^2.1.1 copied to clipboard
Complete state management for Flutter — hierarchical dependency injection, intelligent async caching, and offline-first resilience. Zero boilerplate, automatic cleanup.
Zenify
The state management framework that works the way Flutter works.
Automatic Query Caching • Scoped DI with Auto-Disposal • Offline-First • No Code Generation
Zenify is a complete Flutter state management framework with a built-in TanStack Query engine, offline-first resilience, and hierarchical dependency injection — with zero boilerplate and no code generation.
// Hierarchical DI with automatic cleanup
scope.put<UserService>(UserService());
final service = scope.find<UserService>()!;
// Reactive state that just works
final count = 0.obs();
ZenObserver(() => Text('${count.value}')) // Auto-rebuilds
// Infinite scroll — automatic page management
final feed = ZenInfiniteQuery<PostPage>(
queryKey: 'feed',
initialPageParam: 1,
infiniteFetcher: (page, _) => api.getPosts(page: page),
getNextPageParam: (lastPage, all) => lastPage.hasMore ? all.length + 1 : null,
);
feed.fetchNextPage(); // append next page
feed.hasNextPage.value // know when to stop
feed.isFetchingNextPage.value // drive your loading footer
feed.data.value // all pages, reactive
🎯 The Problem #
Every Flutter developer has written this. Probably many times:
// The async state boilerplate tax — paid on every API call, in every app
bool _isLoading = false;
String? _error;
User? _data;
Future<void> loadUser() async {
setState(() => _isLoading = true);
try {
_data = await api.getUser(id);
_error = null;
} catch (e) {
_error = e.toString();
} finally {
setState(() => _isLoading = false);
}
}
// And if two widgets need the same data? Two separate API calls.
// And if the user goes offline? Blank screen.
// And if the data is stale? Manual cache invalidation logic.
// And if you navigate away and back? Fetch it all over again.
Now here's the same thing with Zenify:
ZenQueryConsumer<User>(
queryKey: 'user:$id',
fetcher: (_) => api.getUser(id),
data: (user) => UserProfile(user),
loading: () => const CircularProgressIndicator(),
error: (e, retry) => ErrorView(e, onRetry: retry),
);
Automatic caching. Deduplication. Background refetch. Stale-while-revalidate. Offline resilience. All built in.
This is what TanStack Query brought to the web. Zenify brings it to Flutter — natively, without code generation, integrated directly into the framework's DI and reactivity system.
🏛️ Built on Flutter's Foundations — No Hidden Global Magic #
This isn't just a design goal — it's enforced throughout the entire framework.
Scoped DI resolves dependencies by walking Flutter's native InheritedWidget tree. There is no mutable currentScope global pointer that changes on every navigation event.
Controller lifecycle is tied directly to State.dispose(). When the widget leaves the tree, the controller is disposed — no framework-level singleton registry involved.
Query cache is scoped per ZenRoute. Navigating away disposes the cache with the scope. There is no shared global cache that leaks state across different user sessions or routes.
Reactivity (.obs(), ZenObserver, ZenUpdater) is built on plain Dart objects and InheritedWidget notifications. There is no global event bus and no hidden dependency tracking.
The result: what you see in your widget tree is exactly what resolves your dependencies. Zenify is predictable, deeply linkable, and fully BuildContext-safe from the ground up — not as a documented limitation, but as an architectural guarantee.
Coming from GetX? The
.obs()reactive syntax and DI verbs (put,find,delete) will feel familiar. Most migration is mechanical. GetX Migration Guide →
Upgrading from V1? The only mechanical change is adding a
controllerparameter to everyZenView.build()override. V2 Migration →
🏗️ Understanding Scopes (The Foundation) #
Every Flutter developer has written this — the manual lifecycle tax:
// Without scoped DI — every feature carries this burden
class _ProfilePageState extends State<ProfilePage> {
late final ProfileController _controller;
late final UserRepository _repo;
late final ProfileImageService _imageService;
@override
void initState() {
super.initState();
_controller = ProfileController();
_repo = UserRepository(apiClient);
_imageService = ProfileImageService(apiClient);
}
@override
void dispose() {
_controller.dispose(); // ← easy to forget
_repo.dispose(); // ← easy to forget
_imageService.dispose(); // ← easy to forget
super.dispose();
// Add a fourth dependency tomorrow? Add another dispose() call.
// Forget one? Silent memory leak. Good luck finding it in production.
}
}
Now here's the same thing with Zenify:
class ProfileModule extends ZenModule {
@override
void register(ZenScope scope) {
final api = scope.find<ApiClient>()!; // resolved from parent scope automatically
scope.putLazy<UserRepository>(() => UserRepository(api));
scope.putLazy<ProfileImageService>(() => ProfileImageService(api));
scope.put<ProfileController>(ProfileController());
}
}
ZenRoute(moduleBuilder: () => ProfileModule(), page: const ProfilePage())
// Navigate away → scope disposed → all three objects cleaned up. Automatically.
// Navigate back → fresh scope, fresh controllers, fresh state. Always.
One ZenRoute. Zero dispose() calls. Zero memory leaks. Zero stale state.
This is what hierarchical scoped DI means in practice. Zenify organizes dependencies into three levels with automatic lifecycle management. When a scope is destroyed, all its children are automatically cleaned up — the cascade is built in.
flowchart TD
classDef root fill:#1A237E,stroke:#3949AB,stroke-width:2px,color:#fff
classDef module fill:#004D40,stroke:#00897B,stroke-width:2px,color:#fff
classDef page fill:#3E2723,stroke:#6D4C41,stroke-width:2px,color:#fff
A["🌐 RootScope<br>(App Lifetime)"]:::root
subgraph Feature_A [Module Scope]
B["📦 AuthModule<br>(Feature Lifetime)"]:::module
C["📄 LoginPage<br>(Page Lifetime)"]:::page
D["📄 RegisterPage<br>(Page Lifetime)"]:::page
end
subgraph Feature_B [Module Scope]
E["📦 CartModule<br>(Feature Lifetime)"]:::module
F["📄 CheckoutPage<br>(Page Lifetime)"]:::page
end
A -->|"Zen.find()"| B
A -->|"Zen.find()"| E
B --> C
B --> D
E --> F
note["♻️ Auto-disposed when<br>navigating away"]
B -.- note
E -.- note
The Three Scope Levels #
RootScope (Global — App Lifetime)
- Services like
AuthService,CartService,ThemeService - Lives for entire app session
- Access anywhere via
Zen.find<CartService>()or the.topattern:CartService.to.addItem()
Module Scope (Feature — Feature Lifetime)
- Controllers shared across feature pages
- Auto-dispose when leaving feature
- Example: HR feature with
CompanyController→DepartmentController→EmployeeController
Page Scope (Page — Page Lifetime)
- Page-specific controllers
- Auto-dispose when page pops
- Example:
LoginController,ProfileFormController
When to Use What #
| Scope | Use For | Lifetime |
|---|---|---|
| RootScope | Needed across entire app | App session |
| Module Scope | Needed across a feature | Feature navigation |
| Page Scope | Needed on one page | Single page |
Learn more about hierarchical scopes →
🚀 Quick Start (30 seconds) #
1. Install #
dependencies:
zenify: ^2.1.1
2. Initialize #
void main() {
Zen.init();
runApp(const MyApp());
}
3. Create a Controller #
class CounterController extends ZenController {
final count = 0.obs();
void increment() => count.value++;
}
4. Provide & Consume #
// 1. Register — in your module, put the controller into its scope
class CartModule extends ZenModule {
@override
void register(ZenScope scope) {
scope.put<CartController>(CartController());
}
}
// In your router — ZenRoute creates the scope and runs the module:
ZenRoute(
moduleBuilder: () => CartModule(),
page: const CartPage(),
)
// 2. Consume — controller is injected directly into build(), zero lookup code
class CartPage extends ZenView<CartController> {
const CartPage({super.key});
@override
Widget build(BuildContext context, CartController controller) {
return Column(
children: [
// 3. React — auto-rebuilds when itemCount changes
ZenObserver(() => Text('${controller.itemCount.value} items')),
ElevatedButton(
onPressed: controller.checkout,
child: const Text('Checkout'),
),
],
);
}
}
That's it. The controller is scoped to the route, auto-disposed on pop, multi-instance safe.
See complete example →
🔥 Core Features #
1. Smart Async State (ZenQuery) #
React Query patterns built on the reactive system. Say goodbye to manual isLoading flags.
sequenceDiagram
participant UI as ZenQueryBuilder
participant Cache as ZenQuery Cache
participant API as Network / DB
UI->>Cache: 1. Request 'user:123'
alt Cache is Fresh
Cache-->>UI: 2. Return cached data immediately
else Cache is Stale
Cache-->>UI: 2. Return stale data instantly
Cache->>API: 3. Fetch fresh data in background
API-->>Cache: 4. New data arrives
Cache-->>UI: 5. UI reactively updates
else Cache is Empty
Cache-->>UI: 2. Yield 'loading' state
Cache->>API: 3. Fetch data
API-->>Cache: 4. Data arrives
Cache-->>UI: 5. Yield 'data' state
end
Path A — Inline (no controller needed):
ZenQueryConsumer<User>(
queryKey: 'user:123',
fetcher: (_) => api.getUser(123),
data: (user) => UserProfile(user),
loading: () => const CircularProgressIndicator(),
error: (error, retry) => ErrorView(error, onRetry: retry),
);
Path B — Shared (query in controller, multiple widgets read it):
class UserController extends ZenController {
late final query = ZenQuery<User>(
queryKey: 'user:123',
fetcher: (_) => api.getUser(123),
config: ZenQueryConfig(staleTime: Duration(minutes: 5)),
);
}
ZenQueryBuilder<User>(
query: controller.query,
builder: (context, user) => UserProfile(user),
loading: () => const CircularProgressIndicator(),
error: (error, retry) => ErrorView(error, onRetry: retry),
);
What you get for free:
- ✅ Automatic caching with configurable staleness
- ✅ Smart deduplication (same key = one request)
- ✅ Background refetch on focus/reconnect
- ✅ Stale-while-revalidate
- ✅ Optimistic updates with rollback
- ✅ Infinite scroll pagination
- ✅ Real-time streams support
- ✅ Tag & wildcard group invalidation
2. Offline-First Resilience #
Offline support isn't a plugin you add. It's how Zenify's query engine is architected. Queries return stale data from disk instantly while fetching fresh data in the background. Mutations that fail when offline are queued, persisted, and automatically replayed when the connection returns.
// Auto-persist data to disk — survives app restarts
final postsQuery = ZenQuery<List<Post>>(
queryKey: 'posts',
fetcher: (_) => api.getPosts(),
config: ZenQueryConfig(
persist: true,
networkMode: NetworkMode.offlineFirst,
),
);
// Queue mutations when offline — auto-replay when back online
final createPost = ZenMutation<Post, Post>(
mutationKey: 'create_post',
mutationFn: (post) => api.createPost(post),
);
Key capabilities:
- Storage agnostic — Hive, SharedPreferences, SQLite, or any
ZenStorageimplementation - Mutation queue — Actions queued and auto-replayed on reconnect
- Optimistic updates — Update UI immediately, sync later
- Network modes — Control how queries behave offline (
offlineFirst,online,always)
3. ZenView — Pages, Screens & Widgets #
By extending ZenView<T>, the controller is injected directly into build(). No context.read<T>(), no BlocBuilder, no ref.watch(). The controller arrives as a typed parameter — see the Quick Start above for the full Register → Consume → React pattern.
React — pick your reactivity tool:
// Rx<T> values — auto-rebuilds on change:
ZenObserver(() => Text('${controller.count.value}'))
// Manual update() calls — selective rebuilds:
ZenUpdater<CartController>(
builder: (ctx, ctrl) => CartBadge(count: ctrl.itemCount),
)
// Async/API state — declarative:
controller.productsQuery.when(
data: (products) => ProductList(products: products),
loading: () => const CircularProgressIndicator(),
error: (e) => Text('Error: $e'),
)
Which pattern for which situation?
| Situation | Pattern |
|---|---|
| App-level services (auth, analytics, database) | Zen.registerModules([AppModule()]) at startup |
| Route with multiple dependencies | ZenRoute(moduleBuilder: () => FeatureModule()) |
| Simple route, single controller | ZenProvider.create<T>(create: ...) |
| App-level singleton (prototyping) | Zen.put<T>(...) at startup |
4. Hierarchical DI with Auto-Cleanup #
Organize dependencies naturally with feature-based modules and parent-child scopes.
// App-level services (persistent)
class AppModule extends ZenModule {
@override
void register(ZenScope scope) {
scope.put<AuthService>(AuthService(), isPermanent: true);
scope.put<DatabaseService>(DatabaseService(), isPermanent: true);
}
}
// Feature-level controllers (auto-disposed on navigation)
class UserModule extends ZenModule {
@override
void register(ZenScope scope) {
final db = scope.find<DatabaseService>()!;
scope.putLazy<UserRepository>(() => UserRepository(db));
scope.putLazy<UserController>(() => UserController());
}
}
// Use with any router — it's just a widget
ZenRoute(
moduleBuilder: () => UserModule(),
page: const UserPage(),
)
Core API:
Zen.put<T>()— Register in root scopeZen.find<T>()— Retrieve (throws if missing)Zen.findOrNull<T>()— Retrieve (returns null if missing)Zen.exists<T>()— Check existenceZen.delete<T>()— Remove from scopeZen.putLazy<T>()— Register a lazy factory
Works with: GoRouter, AutoRoute, Navigator 2.0, any router.
See Hierarchical Scopes Guide →
5. Zero-Boilerplate Reactivity #
Reactive system built on Flutter's ValueNotifier. Simple, fast, no magic.
class TodoController extends ZenController {
final todos = <Todo>[].obs();
final filter = Filter.all.obs();
List<Todo> get filteredTodos {
switch (filter.value) {
case Filter.active: return todos.where((t) => !t.done).toList();
case Filter.completed: return todos.where((t) => t.done).toList();
default: return todos.toList();
}
}
void addTodo(String title) => todos.add(Todo(title));
}
// In UI — automatic, minimal rebuilds
ZenObserver(() => Text('${controller.todos.length} todos'))
ZenObserver(() => ListView.builder(
itemCount: controller.filteredTodos.length,
itemBuilder: (context, i) => TodoItem(controller.filteredTodos[i]),
))
For manual/selective rebuilds, use ZenUpdater:
// Controller:
controller.update(['counter']); // Only notifies 'counter' listeners
// Widget:
ZenUpdater<CounterController>(
id: 'counter',
builder: (context, ctrl) => Text('${ctrl.count}'),
)
💡 Common Patterns #
Global Services with .to Pattern #
class CartService extends ZenService {
static CartService get to => Zen.find<CartService>();
final items = <CartItem>[].obs();
void addToCart(Product product) => items.add(CartItem.fromProduct(product));
}
// Register once at startup
Zen.put<CartService>(CartService(), isPermanent: true);
// Use anywhere — widgets, controllers, helpers
CartService.to.addToCart(product);
Global Reactive State (Theme, Auth, Settings) #
For app-wide reactive state that belongs to no single page, use Zen.put + the .to pattern + ZenObserver. Do not use ZenView for this — ZenView is strictly for page-level controllers scoped to a route.
class ThemeController extends ZenController {
static ThemeController get to => Zen.find<ThemeController>()!;
final isDark = false.obs();
final accentColor = Colors.blue.obs();
void toggleDark() => isDark.value = !isDark.value;
}
// Register once at app startup — isPermanent keeps it alive for the full app session
Zen.put<ThemeController>(ThemeController(), isPermanent: true);
// React to it anywhere in the widget tree — no ZenView, no ZenProvider needed
ZenObserver(() => Icon(
ThemeController.to.isDark.value ? Icons.dark_mode : Icons.light_mode,
))
// Access imperatively in callbacks
ElevatedButton(
onPressed: () => ThemeController.to.toggleDark(),
child: const Text('Toggle Theme'),
)
Rule of thumb: If a controller belongs to one route →
ZenProvider.create+ZenView. If a controller is truly app-wide →Zen.put+.to+ZenObserver.
Infinite Scroll Pagination #
final postsQuery = ZenInfiniteQuery<PostPage>(
queryKey: ['posts'],
infiniteFetcher: (cursor, token) => api.getPosts(cursor: cursor),
);
// Auto-load next page when reaching end
if (index == postsQuery.data.length - 1) postsQuery.fetchNextPage();
Optimistic Updates #
// Easy way — helpers handle rollback automatically
final createPost = ZenMutation.listPut<Post>(
queryKey: 'posts',
mutationFn: (post) => api.createPost(post),
onError: (err, post) => logger.error('Create failed', err),
);
// Advanced — full control
final mutation = ZenMutation<User, UpdateArgs>(
onMutate: (args) => userQuery.data.value = args.toUser(),
onError: (err, args, old) => userQuery.data.value = old,
);
Real-Time Streams #
final chatQuery = ZenStreamQuery<List<Message>>(
queryKey: 'chat',
streamFn: () => chatService.messagesStream,
);
🛠️ Advanced Features #
- Effects — Automatic loading/error/success state management (guide)
- Workers —
ever,debounce,throttle,interval,conditionreactive handlers - Computed values — Auto-updating derived state
- Performance control — Fine-grained:
ZenObserver(reactive) orZenUpdater(manual) - DevTools — Built-in scope/query inspector
📱 Widget Quick Reference #
| Widget | Use When | Rebuilds On |
|---|---|---|
| ZenView | Building pages with controllers | Controller injected into build() |
| ZenRoute | Need module/scope per route | Route navigation |
| ZenObserver | Fine-grained reactive updates | .obs() value changes |
| ZenUpdater | Manual control over rebuild timing | controller.update() call |
| ZenConsumer | Access a controller, no rebuild | Never (manual) |
| ZenQueryConsumer | Fetch data inline, no controller needed | Query state changes |
| ZenQueryBuilder | Shared query instance across widgets | Query state changes |
| ZenStreamQueryBuilder | Real-time data streams | Stream events |
| ZenEffectBuilder | Async operations with loading/error states | Effect state changes |
90% of the time, you'll use:
ZenViewfor pagesZenObserverfor reactive UIZenQueryConsumerfor simple API callsZenQueryBuilderwhen the query is shared across widgets
🔧 Configuration #
void main() {
Zen.init();
// Optional: configure logging
ZenConfig.applyEnvironment(ZenEnvironment.development);
// Optional: set global query defaults
Zen.queryCache.setDefaultConfig(ZenQueryConfig(
staleTime: Duration(minutes: 5),
cacheTime: Duration(hours: 1),
));
runApp(const MyApp());
}
🧪 Testing #
Built for testing from the ground up:
void main() {
setUp(() {
Zen.testMode().clearQueryCache();
});
tearDown(() => Zen.reset());
test('counter increments', () {
final controller = CounterController();
controller.onInit();
controller.increment();
expect(controller.count.value, 1);
controller.dispose();
});
test('mock dependencies', () {
Zen.testMode().mock<ApiClient>(FakeApiClient());
// All code that calls Zen.find<ApiClient>() gets the mock
});
test('query with in-memory storage', () async {
Zen.queryCache.setStorage(InMemoryStorage()); // built-in, zero deps
final q = ZenQuery<String>(
queryKey: 'test',
fetcher: (_) async => 'hello',
config: ZenQueryConfig(persist: true, toJson: (s) => {'v': s}, fromJson: (j) => j['v']),
);
await q.fetch();
expect(q.data.value, 'hello');
});
}
⬆️ Migrating from V1 #
V2 has several breaking changes. The most impactful is mechanical:
// ❌ V1 — magic getter, global registry
class CartPage extends ZenView<CartController> {
@override
Widget build(BuildContext context) {
return Text('${controller.totalItems}');
}
}
// ✅ V2 — explicit injection, tree-bound
class CartPage extends ZenView<CartController> {
const CartPage({super.key});
@override
Widget build(BuildContext context, CartController controller) {
return Text('${controller.totalItems}');
}
}
Full change summary:
| V1 | V2 | Impact |
|---|---|---|
build(BuildContext context) + magic getter |
build(BuildContext context, T controller) |
Breaking — compiler enforces it |
ZenScopeWidget / ZenScopeWidget.create |
ZenProvider / ZenProvider.create |
Breaking — rename |
ZenControllerScope<T>() |
Removed — use ZenProvider.create<T>() |
Breaking — must migrate |
initController override on ZenView |
Removed — use ZenProvider.create at callsite |
Breaking — must migrate |
ZenBuilder<T> |
ZenUpdater<T> — fully renamed |
Breaking — rename required |
Global Zen.put for UI controllers |
ZenProvider scope — no global fallback |
Architectural shift |
🔍 Flutter DevTools Extension #
Zenify has a separate DevTools extension package for real-time inspection and debugging.
Quick Setup #
dev_dependencies:
zenify_devtools_extension: ^1.0.0
void main() {
Zen.init(registerDevTools: true); // registers extensions automatically
runApp(const MyApp());
}
3-Tab Inspector:
- Scope Inspector — Visualize your entire DI hierarchy
- Query Cache Viewer — Monitor, refetch, and invalidate queries
- Metrics Dashboard — Live metrics to identify bottlenecks
🎓 Learning Path #
New to Zenify? Start here:
- 5 minutes: Counter Example — Basic reactivity
- 10 minutes: Todo Example — CRUD with effects
- 15 minutes: ZenQuery Guide — Async state management
- 20 minutes: E-commerce Example — Real-world patterns
- 30 minutes: Offline Demo — Full offline-first app
Building something complex?
- Hierarchical Scopes Guide — Advanced DI
- State Management Patterns — Architecture
- Testing Guide — Unit, widget, integration
📚 Complete Documentation #
Core Guides #
- Reactive Core Guide
- ZenQuery Guide
- Offline-First Guide
- Effects Guide
- Hierarchical Scopes
- State Management Patterns
- Testing Guide
- GoRouter Integration
- GetX Migration Guide
Examples #
- Counter — Simple reactive state
- Todo App — CRUD operations
- E-commerce — Real-world patterns
- Hierarchical Scopes (Flat) — Explicit passing of parent scopes for standard Navigator
- Hierarchical Scopes (Nested) — Canonical Zero-Config DI using go_router ShellRoute
- ZenQuery Demo — Async state management
- Offline Demo — Full offline-first app
- Showcase — All features
🙏 Acknowledgements #
- TanStack Query by Tanner Linsley — For proving that async state deserves a first-class engine
- Riverpod by Remi Rousselet — For hierarchical scoping patterns in Flutter
- GetX by Jonny Borges — For pioneering terse reactive syntax in Flutter
💬 Community & Support #
- Found a bug? Report it
- Have an idea? Discuss it
- Need help? Check our documentation
📄 License #
MIT License — see LICENSE file
🚀 Ready to Get Started? #
flutter pub add zenify
Choose your path:
- New to Zenify? → 5-minute Counter Tutorial
- Want async superpowers? → ZenQuery Guide
- Need offline support? → Offline Guide
- Using GoRouter? → GoRouter Integration
- Coming from GetX? → Migration Guide
- Upgrading from V1? → V2 Migration
- Building something complex? → Hierarchical Scopes Guide
- Setting up tests? → Testing Guide
Experience the zen of Flutter development.