Mastering GetIt for Flutter State Management
A Comprehensive Guide to Registration Types, Lifecycle Management, and Best Practices
Table of Contents
1. Introduction: The Problem We Are Solving
In our codebase, we identified a significant architectural inconsistency causing subtle bugs, reducing code maintainability, and creating confusion among developers. The issue stems from mixing GetIt service locator patterns with BlocProvider widgets in ways that contradict each other's lifecycle management philosophies.
The core problem: Developers register a Bloc as a singleton in GetIt but then use BlocProvider to "provide" that same Bloc to the widget tree. This creates a fundamental conflict:
GetIt expects to manage the Bloc's lifecycle according to its registration type (singleton = permanent)
BlocProvider expects to create and dispose the Bloc based on widget lifecycle
The result? A Bloc that gets disposed by BlocProvider but is still referenced in GetIt's registry, leading to runtime errors like "Cannot emit after bloc closed" or, even worse, the registration being completely removed from GetIt.
2. Problems Caused by Mixing GetIt and BlocProvider
2.1 The Fundamental Conflict
When you register a Bloc as a LazySingleton in GetIt and then use:
BlocProvider(create: (_) => getIt<MyBloc>())
You create two competing lifecycle managers for the same object:
| GetIt LazySingleton | BlocProvider |
|---|---|
| Create once on first access | Create in create callback |
| Keep forever | Dispose when widget removed |
| Never dispose | Automatic disposal |
These philosophies are fundamentally incompatible.
2.2 Documented Issues from Testing
Our comprehensive testing revealed critical issues:
1. Registration Removal (CATASTROPHIC)
When BlocProvider disposes a GetIt-managed singleton, GetIt completely removes the registration:
Bad state: GetIt: Object/factory with type CounterBloc is not registered inside GetIt.
This breaks the entire dependency injection contract!
2. State Loss on Navigation
The Bloc that should persist gets closed when BlocProvider's widget is disposed. All state, streams, and pending operations are lost — the exact bug encountered with the wound detail page where scanned barcode data disappeared when navigating back.
3. Inconsistent Instance Access
With Factory registration, each getIt() call creates a new instance. Code outside BlocProvider gets a different instance than what BlocProvider manages.
4. Debug Difficulty
The bug manifests inconsistently depending on navigation patterns. A singleton might work from one part of the app but fail from another because somewhere else, BlocProvider already disposed it.
2.3 Why This Happens
The root cause is misunderstanding each tool's purpose:
| Tool | Purpose |
|---|---|
| GetIt | Service locator providing DI with configurable lifecycle. Single source of truth for object creation. |
| BlocProvider | Widget providing Blocs to widget tree with automatic disposal. Should create its own instances. |
The anti-pattern: Using BlocProvider just for disposal while having GetIt manage instance creation.
3. GetIt Registration Types: Complete Reference
GetIt provides six distinct registration types, each designed for specific use cases.
3.1 Registration Types Cheat Sheet
| Registration Type | When Created | Instances | Disposal | Best For |
|---|---|---|---|---|
| Singleton | Immediately | One forever | Manual (reset) | Startup services |
| LazySingleton | First access | One forever | Manual (reset) | App-wide services |
| Factory | Every call | Many (new each) | GC (no refs) | Forms, ViewModels |
| CachedFactory | First access | One + GC | Auto (weak ref) | Page-scoped state |
| FactoryParam | Every call | Many (new each) | GC (no refs) | Parameterized forms |
| CachedFactoryParam | First access | Cache by param | Auto (weak ref) | Family + AutoDispose |
3.2 Singleton: Immediate and Permanent
Creates the instance immediately at registration time and maintains it for the application's lifetime.
getIt.registerSingleton<Logger>(Logger());
Use when:
✅ Service needed at app startup
✅ Fast to create (no expensive initialization)
❌ Avoid for slow initialization (use LazySingleton)
3.3 LazySingleton: Deferred but Permanent
Defers instance creation until first access, then persists forever.
getIt.registerLazySingleton<ApiClient>(
() => ApiClient(),
dispose: (client) => client.dispose(),
);
Use when:
✅ Expensive-to-create services (database, HTTP client)
✅ Services not always needed by every user flow
✅ App-wide state (user session, settings)
3.4 Factory: Fresh Instance Every Time
Creates a brand new instance every time getIt<T>() is called.
getIt.registerFactory<LoginFormBloc>(() => LoginFormBloc());
Use when:
✅ Temporary objects (dialogs, forms)
✅ Objects needing fresh state each time
❌ DANGER: NOT for shared state between widgets!
3.5 CachedFactory: The Auto-Dispose Pattern
The star of this guide — creates on first access, caches with a weak reference, auto-disposes when unreferenced.
getIt.registerCachedFactory<WoundDetailBloc>(() => WoundDetailBloc());
How it works:
First call: Creates new instance
Subsequent calls: Returns cached instance (if still in memory)
When no references held + GC runs: Instance cleaned up
This is identical to RiverPod's autoDispose modifier!
Use when:
✅ Page-scoped state (detail pages, forms)
✅ Feature-scoped state that should auto-clean
✅ State that needs to survive navigation within a flow
4. The Two Most Important Patterns (90% of Use Cases)
4.1 Pattern A: LazySingleton for App-Wide State
Perfect for state accessible from anywhere:
getIt.registerLazySingleton<AuthBloc>(
() => AuthBloc(),
dispose: (bloc) => bloc.close(),
);
Use for:
User session and authentication state
Theme and appearance settings
API clients and network managers
Database connections and repositories
RiverPod Equivalent: Provider without autoDispose modifier.
4.2 Pattern B: CachedFactory for Page-Scoped State
Ideal for state persisting within a navigation flow:
getIt.registerCachedFactory<WoundDetailBloc>(() => WoundDetailBloc());
Use for:
Detail page Blocs (wound detail, patient detail)
Form state surviving navigation
Shopping cart and checkout flows
Multi-step wizard flows
RiverPod Equivalent: Provider with autoDispose modifier.
5. Implementing RiverPod's Family + AutoDispose in GetIt
5.1 Family WITH AutoDispose (CachedFactoryParam)
When you need multiple instances keyed by parameter (patient ID, wound ID):
// Registration
getIt.registerCachedFactoryParam<WoundBloc, String, void>(
(woundId, _) => WoundBloc(woundId: woundId),
);
// Usage
final wound123 = getIt<WoundBloc>(param1: 'wound-123');
final wound456 = getIt<WoundBloc>(param1: 'wound-456');
// Each woundId gets its own cached instance!
Behavior:
Creates cached instance per unique parameter
Auto-disposes when unreferenced
Equivalent to RiverPod's
family+autoDispose
5.2 Family WITHOUT AutoDispose (Permanent Per-Parameter Instances)
When you need parameterized instances that persist permanently (never GC'd):
// Create a permanent cache map with strong references
final Map<String, PatientBloc> _permanentFamily = {};
// Helper method using putIfAbsent
PatientBloc getPermanentBloc(String patientId) {
return _permanentFamily.putIfAbsent(
patientId,
() => PatientBloc(patientId: patientId),
);
}
// Register with GetIt
getIt.registerFactoryParam<PatientBloc, String, void>(
(patientId, _) => _permanentFamily.putIfAbsent(
patientId,
() => PatientBloc(patientId: patientId),
),
);
How putIfAbsent works:
If key exists → returns existing value
If key doesn't exist → creates new value, stores it, returns it
Why it never gets GC'd: The Map holds strong references (not weak), so garbage collector cannot reclaim instances.
Equivalent to: RiverPod's family modifier WITHOUT autoDispose
⚠️ Warning: Use carefully. Since instances never dispose, implement a clearCache() method if memory becomes a concern.
6. Using Scopes for Login/Logout and Feature Toggles
GetIt's scope system provides hierarchical lifecycle management independent of the widget tree.
Login/Logout Pattern
// Base scope - guest mode
getIt.registerSingleton<User>(GuestUser());
// On login - push authenticated scope
getIt.pushNewScope(scopeName: 'authenticated');
getIt.registerSingleton<User>(AuthenticatedUser(token));
// Now getIt<User>() returns AuthenticatedUser (shadows GuestUser)
// On logout - pop scope (GuestUser restored!)
await getIt.popScope();
How shadowing works:
Push new scope → registrations shadow previous ones
Pop scope → previous registrations automatically restored
Perfect for:
Authentication state switching
Feature flags / A-B testing
Multi-tenant applications
Session management
7. Benefits of Migrating to Pure GetIt
1. Single Source of Truth
GetIt becomes the sole authority for object creation and lifecycle. No confusion about whether a Bloc is managed by GetIt or BlocProvider.
2. Improved Code Readability
Check one place (GetIt registration) to understand how a Bloc is provided. No need to juggle between router (BlocProvider) and service locator.
3. Elimination of Subtle Bugs
No more:
"bloc closed" errors
Disappearing state on navigation
Mystery registration removals
4. Easier Debugging
Straightforward investigation path:
Check registration type in GetIt
Verify access pattern matches registration contract
Use GetIt DevTools extension for visibility
5. Better Testability
setUp(() {
configureDependencies();
getIt.pushNewScope();
getIt.registerSingleton<ApiClient>(MockApiClient());
});
tearDown(() async {
await getIt.popScope();
});
6. Consistent Patterns
Team standardizes on:
LazySingletonfor app-wideCachedFactoryfor page-scopedCachedFactoryParamfor parameterized
8. Anti-Patterns to Avoid
❌ Anti-Pattern 1: BlocProvider with GetIt-Managed Blocs
// WRONG!
BlocProvider(create: (_) => getIt<MyBloc>())
Result: BlocProvider disposes what GetIt manages. Registration may be completely removed!
❌ Anti-Pattern 2: Factory for Shared State
// WRONG!
getIt.registerFactory<SharedStateBloc>(() => SharedStateBloc())
Result: Each getIt() call creates a new instance. Different widgets get different instances with independent state.
❌ Anti-Pattern 3: Singleton for Page-Scoped State
// WRONG!
getIt.registerLazySingleton<DetailPageBloc>(() => DetailPageBloc())
Result: Bloc persists forever, accumulating state from multiple visits. Patient A's data might remain when viewing Patient B.
9. Correct Implementation Patterns
✅ App-Wide Service
getIt.registerLazySingleton<AuthBloc>(
() => AuthBloc(),
dispose: (bloc) => bloc.close(),
);
✅ Page-Scoped State
getIt.registerCachedFactory<WoundDetailBloc>(() => WoundDetailBloc());
✅ Parameterized State WITH AutoDispose
getIt.registerCachedFactoryParam<PatientBloc, String, void>(
(patientId, _) => PatientBloc(patientId),
);
✅ Parameterized State WITHOUT AutoDispose
final Map<String, PatientBloc> _cache = {};
getIt.registerFactoryParam<PatientBloc, String, void>(
(id, _) => _cache.putIfAbsent(id, () => PatientBloc(id)),
);
10. Conclusion and Next Steps
The transition from mixed GetIt/BlocProvider usage to pure GetIt patterns yields significant long-term benefits.
Immediate Actions
Identify all BlocProvider usages in the codebase
Classify each by intended lifecycle:
App-wide →
LazySingletonPage-scoped →
CachedFactoryParameterized + auto-dispose →
CachedFactoryParamParameterized + permanent →
FactoryParam+ Map
Quick Reference Table
| Need | GetIt Type | RiverPod Equivalent |
|---|---|---|
| App-wide state | LazySingleton |
Provider (no autoDispose) |
| Page-scoped state | CachedFactory |
Provider + autoDispose |
| Parameterized + auto-clean | CachedFactoryParam |
family + autoDispose |
| Parameterized + permanent | FactoryParam + Map |
family (no autoDispose) |
Final Thoughts
By adopting these patterns consistently, you'll:
Eliminate an entire class of subtle bugs
Improve code maintainability
Create a clearer architectural foundation
Reduce cognitive load for architectural decisions
These patterns are battle-tested and directly equivalent to RiverPod's proven provider patterns, making them a reliable choice for production applications.
Happy coding! 🚀
References:
watch_it Package - For reactive UI updates with GetIt


