Dependency injection & services
A state hook that builds its own dependencies is not testable and not reusable:
// DON'T - the hook constructs its infrastructure
TasksScreenState useTasksScreenState() {
final service = TaskService(apiClient: ApiClient()); // <- tight coupling, no way to fake it
// ...
}
The fix is dependency injection: the hook asks for a service by type and gets whatever the app registered. In utopia_hooks this is a single hook, useInjected<T>(), declared once over whatever container the project uses.
// DO - the hook receives its dependency
TasksScreenState useTasksScreenState() {
final service = useInjected<TaskService>(); // <- resolved from the provided container
// ...
}
Services are stateless infrastructure wrappers
A clean split runs through the whole architecture: services own infrastructure, hooks own state.
A service is the only place that touches Firebase, gRPC, SharedPreferences, the file system, or HTTP. It exposes methods returning Stream<T>, Future<T>, or a synchronous T, and holds no mutable state of its own. A hook calls useInjected<Service>() and feeds the service's streams and futures into useMemoizedStream, useAutoComputedState, or useSubmitState. The hook never learns how the data is stored - only what to ask for.
// DON'T - the hook reaches into infrastructure directly
ProfileState useProfileState() {
final dataState = useAutoComputedState(
() async => database.collection('profiles').doc(userId).get(), // <- infra in the hook
);
// ...
}
// DO - a service wraps the infrastructure, the hook calls the service
ProfileState useProfileState() {
final profileService = useInjected<ProfileService>();
final dataState = useAutoComputedState(
() async => profileService.load(userId), // <- the hook doesn't know how or where
);
// ...
}
Splitting a large service by responsibility (Firebase vs API vs local data) keeps each one focused and its tests isolated.
utopia_injector
utopia_injector is the container utopia_hooks apps usually reach for. An Injector is built before runApp by registering dependencies against a register API:
final injector = Injector.build((register) {
register.singleton((injector) => ApiClient());
register.singleton((injector) => TaskService(injector<ApiClient>()));
register.instance<AppConfig>(const AppConfig(/* ... */));
});
The register API covers the common lifetimes:
register.singleton((i) => ...)- lazily built once, then the same instance is returned on everyget.register.provider((i) => ...)- rebuilt on everyget, for values that should not be shared.register.instance(value)- an already-constructed value.register.factory(...)/register.raw(type, factory)- register a customInjectorFactory, or register against aTypeknown only at runtime.
Each builder receives the Injector, so a dependency can resolve its own dependencies (injector<ApiClient>(), or the shorthand injector.get<ApiClient>()). Asking for a type that was never registered throws NotDefinedException; a dependency that depends on itself throws CircularDependencyException.
The container itself - lifetimes, overrides, child injectors - is the package's own concern. From the hooks side, all that matters is that the Injector exists and is reachable.
The useInjected bridge
useInjected is a one-line hook built on useProvided. It looks up the Injector from the tree and resolves the requested type:
T useInjected<T>() => useProvided<Injector>().get();
So the Injector has to be provided. It is registered as the first entry in the root HookProviderContainerWidget map, above every state that needs to inject anything:
const _providers = <Type, Object? Function()>{
Injector: AppInjector.use, // <- first, so everything below it may useInjected
AuthState: useAuthState,
CoursesState: useCoursesState,
// ...
};
If the project uses utopia_arch, that package ships exactly this useInjected ready-made - import it rather than redeclaring. Keep only one useInjected in scope.
useInjected resolves through useProvided<Injector>(), so a missing Injector entry fails at useProvided<Injector> before useInjected ever runs. If a specific useInjected<T>() throws instead, the Injector is present but T was never registered in it.
Where useInjected is allowed
useInjected is only valid in a hook context, and only in the layers that own logic:
| Location | Allowed? |
|---|---|
Screen state hook (useXScreenState) | Yes |
Global state hook (useXState in _providers) | Yes |
| Custom hook called from one of the above | Yes |
View (StatelessWidget.build) | No - not a hook context, and Views stay service-free |
Screen widget (HookWidget.build) | No - the Screen is pure wiring; services go in the state hook |
Resolve the service once during the hook build, then use it from inside callbacks - do not call useInjected inside a Future or a callback body:
TasksScreenState useTasksScreenState() {
final taskService = useInjected<TaskService>(); // <- resolve during build
final analyticsService = useInjected<AnalyticsService>();
final deleteState = useSubmitState();
void deleteTask(TaskId id) => deleteState.runSimple<void, Never>(
submit: () async {
await taskService.delete(id); // <- use it inside the callback
analyticsService.track('task_deleted');
},
);
return TasksScreenState(onDeletePressed: deleteTask, isDeleting: deleteState.inProgress, /* ... */);
}
Bridging a different container
useInjected does not care which container sits underneath - it only needs one line over whatever the project already has. If the project uses a different DI solution, write that one line and skip utopia_injector entirely.
For get_it:
T useInjected<T extends Object>() => GetIt.I<T>();
For package:provider or another BuildContext-based container, bridge through the context. useProvided<T>() does not read package:provider, so resolve it from the BuildContext instead:
T useInjected<T>() => Provider.of<T>(useBuildContext(), listen: false);
Either way, keep exactly one useInjected in scope so every hook resolves services the same way.
See also
- Screen / State / View -
useInjectedlives in the state hook - Global state - injecting services inside global state hooks, and the ordered
_providersmap - Provider -
useProvided, whichuseInjectedis built on - useProvided - the underlying lookup
- Testing - faking injected services in tests