Skip to main content

Command Palette

Search for a command to run...

Mastering GetIt for Flutter State Management

Updated
10 min read

A Comprehensive Guide to Registration Types, Lifecycle Management, and Best Practices


Table of Contents

  1. Introduction: The Problem We Are Solving

  2. Problems Caused by Mixing GetIt and BlocProvider

  3. GetIt Registration Types: Complete Reference

  4. The Two Most Important Patterns (90% of Use Cases)

  5. Implementing RiverPod's Family + AutoDispose in GetIt

  6. Using Scopes for Login/Logout and Feature Toggles

  7. Benefits of Migrating to Pure GetIt

  8. Anti-Patterns to Avoid

  9. Correct Implementation Patterns

  10. Conclusion and Next Steps


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:

  1. First call: Creates new instance

  2. Subsequent calls: Returns cached instance (if still in memory)

  3. 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:

  1. Push new scope → registrations shadow previous ones

  2. 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:

  1. Check registration type in GetIt

  2. Verify access pattern matches registration contract

  3. 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:

  • LazySingleton for app-wide

  • CachedFactory for page-scoped

  • CachedFactoryParam for 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

  1. Identify all BlocProvider usages in the codebase

  2. Classify each by intended lifecycle:

    • App-wide → LazySingleton

    • Page-scoped → CachedFactory

    • Parameterized + auto-dispose → CachedFactoryParam

    • Parameterized + 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: