Skip to content

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 itself
class 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()
}
}
Compiled React 19 — this is the file in your repo
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 consumer
export component CustomerName {
inject service customerService // the entire consumer API
// … customerService.get(id) …
}
Compiled React 19 — this is the file in your repo
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 environment
export component TestHarness {
provide customerService with mockCustomerService // descendants get the mock
view { <Dashboard /> }
}
Compiled React 19 — this is the file in your repo
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 TestHarness

  • createContext + <ServicesProvider> + a useXService hook — per serviceinject service X
  • Wiring a service’s own dependencies by handinject service authService inside the service; the compiler resolves the singleton
  • Building swap machineryprovide X with Y overrides X for the whole subtree
  • DI tokens, provider arrays, module config → none; the names are the wiring

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.