Skip to main content

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 every get.
  • register.provider((i) => ...) - rebuilt on every get, for values that should not be shared.
  • register.instance(value) - an already-constructed value.
  • register.factory(...) / register.raw(type, factory) - register a custom InjectorFactory, or register against a Type known 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.

note

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:

LocationAllowed?
Screen state hook (useXScreenState)Yes
Global state hook (useXState in _providers)Yes
Custom hook called from one of the aboveYes
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