Dependency injection without the DI framework
A customerService that talks to your API (using an authService for tokens),
shared across the app — and swappable for a mock in tests.
That last clause is the catch. Sharing a service in React is easy (import a singleton). Making it swappable — for tests, for a staging backend, for one subtree — is where you end up hand-building DI plumbing.
React — swappable services mean Context plumbing (per service)
Section titled “React — swappable services mean Context plumbing (per service)”import { createContext, useContext, type ReactNode } from "react"
// the service itselfclass CustomerService { constructor(private auth: AuthService) {} async get(id: string) { const token = await this.auth.getToken() const res = await fetch(`/api/customers/${id}`, { headers: { Authorization: `Bearer ${token}` } }) return res.json() }}
// to make it swappable you wrap it in context…const CustomerServiceCtx = createContext<CustomerService | null>(null)
export function ServicesProvider({ children }: { children: ReactNode }) { const auth = new AuthService() const customerService = new CustomerService(auth) // wire deps by hand return <CustomerServiceCtx.Provider value={customerService}>{children}</CustomerServiceCtx.Provider>}
export function useCustomerService() { const svc = useContext(CustomerServiceCtx) if (!svc) throw new Error("useCustomerService must be inside <ServicesProvider>") return svc}…and you do that for each service you want to override, plus wire each service’s own dependencies by hand in the provider. (Import a bare singleton instead and it’s simpler — but then it’s not swappable, which was the point.)
Reactra — service, inject service, provide
Section titled “Reactra — service, inject service, provide”export service authService { action async getToken() { return localStorage.getItem("token") ?? "" }}
export service customerService { inject service authService // service-to-service, wired by the compiler action async get(id: string) { const token = await authService.getToken() const res = await fetch(`/api/customers/${id}`, { headers: { Authorization: `Bearer ${token}` } }) return res.json() }}import { ServiceRegistry, createServiceInstance } from "@reactra/service"
export const authService = { name: "authService", factory: () => createServiceInstance(() => { const getToken = async () => { return localStorage.getItem("token") ?? "" } return { getToken } }),}
export const customerService = { name: "customerService", factory: () => createServiceInstance(() => { const authService = ServiceRegistry.get("authService") const get = async (id: string) => { const token = await authService.getToken() const res = await fetch(`/api/customers/${id}`, { headers: { Authorization: `Bearer ${token}` } }) return res.json() } return { get } }),}
if (import.meta.hot) { import.meta.hot.accept((newMod) => { if (!newMod) return ServiceRegistry.replace(newMod.authService) ServiceRegistry.replace(newMod.customerService) })}// any consumerexport component CustomerName { inject service customerService // the entire consumer API // … customerService.get(id) …}import { useEffect } from "react"import { ServiceRegistry, useService } from "@reactra/service"
export const CustomerName = () => { const customerService = useService("customerService") useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("CustomerName", {}) }) return null}export default CustomerName// swap it — for a test, a subtree, an environmentexport component TestHarness { provide customerService with mockCustomerService // descendants get the mock view { <Dashboard /> }}import { use, useEffect, useMemo } from "react"import { ServiceContext, ServiceRegistry, composeOverrides } from "@reactra/service"
export const TestHarness = () => { const __reactraParentOverrides = use(ServiceContext) const __reactraOverrides = useMemo(() => composeOverrides(__reactraParentOverrides, { "customerService": ServiceRegistry.get("mockCustomerService") }), [__reactraParentOverrides]) useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("TestHarness", {}) }) return (<ServiceContext.Provider value={__reactraOverrides}><> <Dashboard /> </></ServiceContext.Provider>)}export default TestHarnessWhat disappeared
Section titled “What disappeared”createContext+<ServicesProvider>+ auseXServicehook — per service →inject service X- Wiring a service’s own dependencies by hand →
inject service authServiceinside the service; the compiler resolves the singleton - Building swap machinery →
provide X with Yoverrides X for the whole subtree - DI tokens, provider arrays, module config → none; the names are the wiring
It’s not a new runtime
Section titled “It’s not a new runtime”A service compiles to a closure factory registered in a small ServiceRegistry;
inject service X becomes useService("X") in a component (or
ServiceRegistry.get("X") inside another service). provide X with Y composes a
ServiceContext override via React 19’s use() — no useContext, no proxy.
Singletons are resolved at compile time; there’s zero runtime DI overhead for the
common case.
Inject by name. Override by subtree. No framework to configure.