Impulse
Easy and simple state management solution that mainly functions as a dependency injection service and integrates well with other state management solutions.
Impulse provides a lightweight way to manage shared state and dependencies using a central Store and type-safe References.
The package is currently being implemented in some production-level code to validate its real-world use. It will hit
1.0after this is complete.
Quick start
Add Impulse to your project:
dart pub add impulse
Below is a minimal example showing how to define a reference, retrieve it, watch for updates, and notify dependents:
import 'package:impulse/impulse.dart';
// 1. Define a Reference (Ref)
final counterRef = Ref((store) => Counter());
class Counter extends ImpulseNotifier {
int count = 0;
void increment() {
count += 1;
notify(); // Notifies the store and all listening dependents
}
}
void main() async {
// 2. Watch for changes (using the global $store)
final unwatch = $store.watch(counterRef(), (counter) {
print('Count is ${counter.count}');
});
// 3. Retrieve the instance and update it (using the preferred syntax)
counterRef.get($store).increment(); // (or $store.get(counterRef()) if you prefer)
// Cleanup when done
unwatch();
$store.reset();
}
Refs & the store
The Store
The Store is the central container where all of your dependencies and shared states live.
- Global Store: Impulse provides a global default store instance named
$store. For most applications, this is the only store you will need. - Local Stores: You can construct a new isolated store via
Store(). This is particularly useful for hermetic testing or scoping specific modules of an application.
Key Store API methods:
store.get(ref()): Retrieves or initializes the object associated with the reference.store.init(ref()): Initializes a reference immediately without returning its value.store.watch(ref(), callback): Listens for notifications from the reference's object and invokes the callback. Returns an unwatch function.store.drop(ref()): Manually removes the reference's object from the store and disposes of it.store.reset(): Disposes of all stored objects and clears the store.store.reassemble(): Forces a re-evaluation of all dependencies (highly useful for Flutter's Hot Reload).
Note: While
store.get(ref())is fully supported, it is generally preferred to useref.get(store)because it is shorter and more direct! Both styles are supported throughout the framework.
Reference Types
References define how dependencies are created, cached, and disposed. Impulse provides three primary reference types:
1. Ref<T> (Singleton Reference)
Caches a single instance of T globally within the store.
final authServiceRef = Ref(
(store) => AuthService(),
dispose: (service) => service.close(),
);
2. FactoryRef<T> (Factory Reference)
Never caches the value. It creates and returns a brand-new instance of T every time it is requested from the store.
final uuidRef = FactoryRef((store) => const Uuid().v4());
3. FamilyRef<T, R> (Parametrized Reference)
Caches unique instances based on an input parameter of type R. Perfect for parametrized data fetches or services.
final userProfileRef = FamilyRef<UserProfile, String>(
(store, userId) => UserProfile(userId: userId),
);
// Usage:
final profileA = userProfileRef.get(store, 'alice');
final profileB = userProfileRef.get(store, 'bob');
ImpulseNotifier and error handling
ImpulseNotifier
State objects can extend ImpulseNotifier to gain reactive capabilities. ImpulseNotifier implements ImpulseListenable and Disposable under the hood. When your state class calls notify(), all dependent boxes and active watchers are notified immediately, triggering cascading updates.
class ThemeState extends ImpulseNotifier {
bool isDarkMode = false;
void toggle() {
isDarkMode = !isDarkMode;
notify(); // Automatically triggers invalidation of any dependent Refs
}
}
Error Handling with Result<T> and attempt
Impulse includes a functional error-handling utility to deal with operations that might fail (e.g., network requests, file I/O).
Result<T>: A type alias representing the record(T? value, Err? err).attempt: A utility function that wraps an asynchronous execution, returning aResult<T>without throwing.MapResultExtension: Exposes a.map()method to gracefully handle the success, failure, or empty state of aResult.
import 'package:impulse/impulse.dart';
Future<String> fetchData() async {
// Can throw an error
return throw Exception('Network timeout');
}
void main() async {
final Result<String> (value, err) = await attempt(() => fetchData());
if (err != null) {
print('Fetch failed: ${err.error}');
return;
}
print('Fetched value: $value');
}
Testing
Impulse makes testing simple and hermetic by providing dependency overrides and allowing you to instantiate local, isolated stores.
1. Using Overrides
You can mock or stub any reference in the store. When a reference is overridden, any dependent references will automatically adapt and use the overridden version.
import 'package:test/test.dart';
import 'package:impulse/impulse.dart';
final apiRef = Ref((store) => RealApiService());
final repositoryRef = Ref((store) => UserRepository(apiRef.get(store)));
class MockApiService implements RealApiService {
@override
Future<String> getUserName() async => 'Mock User';
}
void main() {
late Store store;
setUp(() {
store = Store(); // Use a local store instead of global $store
});
tearDown(() {
// Reset the store to dispose of all objects and prevent tests from leaking state
store.reset();
});
test('UserRepository uses the overridden API service', () async {
// Override the RealApiService with MockApiService on this store
store.override(apiRef(), (store) => MockApiService());
final repo = repositoryRef.get(store);
expect(await repo.getUserName(), equals('Mock User'));
});
}
2. Isolation & Resetting
- Isolation: Always use local, isolated
Store()instances in your tests instead of the global$storeto ensure tests run in isolation and do not share state. - Resetting: In your test suite's
tearDownorsetUpcallback, callstore.reset(). This guarantees that all cached references are completely cleared and resources (like controllers or listeners) are properly disposed of, avoiding state bleeding between tests.
Advanced
Interfaces
Impulse relies on a set of core abstract interfaces to manage object lifecycles:
Disposable: An interface for classes that require manual resource cleanup.abstract class Disposable { void dispose(); }ImpulseListenable: An interface for objects that can be listened to for state changes.abstract class ImpulseListenable { void addListener(Listener listener); void removeListener(Listener listener); }ReactivityAdapter: An adapter interface defining how to bind to and dispose of specific object types within the store.abstract class ReactivityAdapter { void Function()? onBind(dynamic value, void Function() notify); void onDispose(Store store, dynamic value); }
Reactivity delegate (with example for BLoC)
The ReactivityDelegate coordinates custom bindings and disposals. By default, it supports objects implementing ImpulseListenable and Disposable. However, you can register custom ReactivityAdapters to support external libraries or other state management solutions (like BLoC or Streams).
Here is an example adapter for integrating BLoC/Cubit:
import 'package:bloc/bloc.dart';
import 'package:impulse/impulse.dart';
class BlocReactivityAdapter implements ReactivityAdapter {
const BlocReactivityAdapter();
@override
void Function()? onBind(dynamic value, void Function() notify) {
if (value is BlocBase) {
// Whenever the Bloc/Cubit emits a new state, notify downstream dependents
final subscription = value.stream.listen((_) => notify());
return () => subscription.cancel();
}
return null;
}
@override
void onDispose(Store store, dynamic value) {
if (value is BlocBase) {
// Automatically close the Bloc when it is dropped from the store
value.close();
}
}
}
// Option A: Register the adapter on the global default `$store`
$store.reactivity.addAdapter(const BlocReactivityAdapter());
// Option B: Register the adapter when instantiating a custom local Store
final customStore = Store(
delegate: ReactivityDelegate(
adapters: [const BlocReactivityAdapter()],
),
);
Scopes
The withScope function allows you to temporarily retain a reference in the store for the duration of an asynchronous callback. Once the callback completes (or throws), the reference is released. If its reference count drops to 0, it is automatically dropped and cleaned up.
final tempCacheRef = Ref((store) => TemporaryCache());
void main() async {
final result = await withScope(
(store) async {
final cache = store.get(tempCacheRef());
return await cache.loadSessionData();
},
store: $store,
ref: tempCacheRef(),
);
// Outside the scope, tempCacheRef has been automatically released and disposed!
print($store.exists(tempCacheRef())); // false
}
The box model
Under the hood, Impulse organizes references in a directed dependency graph using a container called ImpulseBox.
[Dependent Box]
│
├─► (reads & watches) ──► [Dependency Box A]
└─► (reads & watches) ──► [Dependency Box B]
When you call store.get(ref) or store.watch(ref):
- Lazy Evaluation: The reference's
createcallback is only evaluated when needed. - Automatic Dependency Tracking: During evaluation, Impulse sets an active evaluation context. If your creator callback reads another reference (e.g.,
store.get(otherRef)), Impulse dynamically registers thatotherRef's box as a dependency, and the current box as a dependent. - Reactive Invalidation: When a dependency notifies (or is replaced/reset), it recursively invalidates and resets all its dependents, causing them to re-evaluate and rebuild their state seamlessly.
- Reference Counting & GC: Every dependency relation acts as a retain lock. If a box is not configured with
keepAlive: true, it tracks the number of active dependents and manual watchers. As soon as this count hits zero, the box cleanly tears itself down (calling theReactivityDelegate'sonDisposeand its own customdisposecallback) and removes itself from the store to save memory.
See also
- impulse_flutter for a flutter integration using this package.
- impulse_signals for a signals addon to this package.
- API reference for a detailed description of all API points.
License
This project is licensed under the MIT License.
Libraries
- impulse
- A minimalist state management and dependency injection library.