GetX Binding Scope

pub package License: MIT

Scoped dependency injection for GetX β€” automatic cleanup, async safety, and nested bindings made easy.

Overview

A lightweight package offering scoped dependency injection using GetX-style Bindings, but decoupled from GetPage. Register dependencies with DI, wrap widgets in BindingScope, and your dependencies are cleaned up automaticallyβ€”no more lifecycle headaches.

Perfect for Flutter apps that want the power of GetX dependency injection without being locked into GetX navigation.

✨ Key Features

  • πŸ—‚ Scoped DI: Dependencies live only within their widget scope
  • 🧹 Automatic Cleanup: Only deletes what a scope created - no memory leaks
  • ⏳ Async Safe: First-registrant-wins behavior, handling putAsync races gracefully
  • πŸ”„ Reverse-Order Teardown: Maintains dependency integrity during cleanup
  • πŸ—οΈ Nested Scopes: Prevents ownership bleed between nested bindings
  • πŸš€ Router-Agnostic: Works with go_router, auto_route, Navigator 2.0, or any routing solution
  • 🎯 Zero Configuration: Drop-in replacement for GetX bindings with zero setup

πŸ“¦ Installation

Add this to your package's pubspec.yaml file:

dependencies:
  getx_binding_scope: ^1.0.0
  get: ^4.7.2

Then run:

flutter pub get

πŸš€ Quick Start

1. Define a Binding with DI

import 'package:getx_binding_scope/getx_binding_scope.dart';
import 'package:get/get.dart';

class HomeBinding extends Bindings {
  @override
  void dependencies() {
    // Immediate registration
    DI.put<Config>(Config());
    
    // Lazy registration (created when first accessed)
    DI.lazyPut<ApiService>(() => ApiService(DI.find<Config>()));
    
    // Async registration with automatic race condition handling
    DI.putAsync<UserSession>(() async {
      final token = await AuthService.fetchToken();
      return UserSession(token);
    });
    
    // Factory registration (new instance each time)
    DI.create<Logger>(() => Logger('${DateTime.now()}'));
  }
}

2. Wrap Your Widget in BindingScope

import 'package:flutter/material.dart';
import 'package:getx_binding_scope/getx_binding_scope.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return BindingScope(
      binding: HomeBinding(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: const HomeContent(),
      ),
    );
  }
}

class HomeContent extends StatelessWidget {
  const HomeContent({super.key});

  @override
  Widget build(BuildContext context) {
    // Access dependencies anywhere in the widget tree
    final apiService = DI.find<ApiService>();
    final config = DI.find<Config>();
    
    return Column(
      children: [
        Text('API Base URL: ${config.baseUrl}'),
        FutureBuilder<List<User>>(
          future: apiService.getUsers(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              return ListView.builder(
                shrinkWrap: true,
                itemCount: snapshot.data!.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(snapshot.data![index].name),
                  );
                },
              );
            }
            return const CircularProgressIndicator();
          },
        ),
      ],
    );
  }
}

πŸ”„ Advanced Usage

Nested Scopes

BindingScope supports nesting without ownership conflicts:

class ParentPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BindingScope(
      binding: ParentBinding(), // Creates ParentService
      child: Column(
        children: [
          Text('Parent: ${DI.find<ParentService>().name}'),
          ChildWidget(), // Has its own scope
        ],
      ),
    );
  }
}

class ChildWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BindingScope(
      binding: ChildBinding(), // Creates ChildService
      child: Column(
        children: [
          Text('Parent: ${DI.find<ParentService>().name}'), // Still accessible
          Text('Child: ${DI.find<ChildService>().name}'),   // Only in this scope
        ],
      ),
    );
  }
}

Working with Different Routers

With GoRouter

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => BindingScope(
        binding: HomeBinding(),
        child: const HomePage(),
      ),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => BindingScope(
        binding: ProfileBinding(),
        child: const ProfilePage(),
      ),
    ),
  ],
);

With AutoRoute

@AutoRouteConfig()
class AppRouter extends _$AppRouter {
  @override
  List<AutoRoute> get routes => [
    AutoRoute(
      page: HomeWrapperRoute.page,
      path: '/home',
    ),
  ];
}

@RoutePage()
class HomeWrapperPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BindingScope(
      binding: HomeBinding(),
      child: const HomePage(),
    );
  }
}

Async Dependencies Best Practices

class AsyncBinding extends Bindings {
  @override
  void dependencies() {
    // Configure services that will be needed
    DI.put<Config>(Config());
    
    // Start async operations early
    DI.putAsync<DatabaseService>(() async {
      final db = DatabaseService();
      await db.initialize();
      return db;
    });
    
    // Dependent async services work correctly
    DI.putAsync<UserRepository>(() async {
      final db = await DI.find<DatabaseService>(); // Waits if still loading
      return UserRepository(db);
    });
  }
}

πŸ“‹ Migration from GetX

Switching from GetX Bindings? Here's how to update:

Before (GetX) After (getx_binding_scope)
Get.put() DI.put()
Get.lazyPut() DI.lazyPut()
Get.putAsync() DI.putAsync()
Get.create() DI.create()
Get.find() DI.find()
GetPage(binding: ...) BindingScope(binding: ...)

Migration Example

// Before - GetX with routing
GetPage(
  name: '/home',
  page: () => HomePage(),
  binding: HomeBinding(),
)

// After - BindingScope (router agnostic)
BindingScope(
  binding: HomeBinding(),
  child: HomePage(),
)

⚠️ Important Notes

Lifecycle Management

  • Automatic Cleanup: Dependencies are automatically disposed when BindingScope is removed from the widget tree
  • Reverse Order: Dependencies are disposed in reverse creation order, ensuring proper cleanup
  • Async Safety: Async dependencies that finish after scope disposal are still cleaned up properly

Best Practices

  1. One Binding Per Feature: Create separate bindings for different features/pages
  2. Dependency Hierarchy: Place shared dependencies in parent scopes
  3. Avoid Global State: Use scoped dependencies instead of global singletons when possible
  4. Testing: Mock dependencies by creating test-specific bindings

Performance Considerations

  • Lazy Loading: Use DI.lazyPut() for expensive objects that might not be used immediately
  • Memory Efficiency: Scoped cleanup prevents memory leaks from accumulated dependencies
  • Async Handling: First-registrant-wins prevents duplicate async operations

πŸ§ͺ Testing

class TestBinding extends Bindings {
  @override
  void dependencies() {
    DI.put<ApiService>(MockApiService()); // Use mocks in tests
    DI.put<UserRepository>(MockUserRepository());
  }
}

testWidgets('HomePage displays user data', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: BindingScope(
        binding: TestBinding(),
        child: HomePage(),
      ),
    ),
  );
  
  // Test your widget with mocked dependencies
  expect(find.text('Mock User Name'), findsOneWidget);
});

πŸ“š API Reference

DI Class

  • DI.put<T>(T instance) - Register an immediate instance
  • DI.lazyPut<T>(T Function() builder) - Register a lazy factory
  • DI.putAsync<T>(Future<T> Function() builder) - Register an async instance
  • DI.create<T>(T Function() builder) - Register a factory (new instance each find)
  • DI.find<T>() - Retrieve a registered instance
  • DI.isRegistered<T>() - Check if a type is registered

BindingScope Widget

BindingScope({
  Key? key,
  required Bindings binding,
  required Widget child,
})
  • binding: The GetX binding to execute
  • child: The widget subtree that will have access to the dependencies

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.


Built with ❀️ for the Flutter community

Libraries

getx_binding_scope