Dependency injection
Many newcomers instantiate services with new inside components or pull in global singletons. That works at first, but as the app grows you tend to hit:
- Mixed responsibilities—business logic and dependency wiring live together and components balloon;
- Hard swaps between implementations (e.g. prod vs test loggers) scattered across files;
- Painful testing because substitutes (mocks, fake implementations) are awkward to plug in.
Dependency injection (DI) addresses this by moving “how dependencies are created/replaced” out of component business logic. Components only say what they need and use it.
In practice: register dependencies upstream, let components declare needs by token or type, and let the container resolve instances at runtime.
Reach for DI when:
- A dependency should be shared across components;
- Implementations should vary by environment or feature slice;
- You want clearer tests and maintenance.
In Viewfly, a minimal mental model is enough to start: define → register → inject.
Prerequisites
DI samples use decorators such as @Injectable(). In TypeScript projects enable:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Ensure reflect-metadata loads before DI runs (usually via @viewfly/core’s main entry; if not, add import 'reflect-metadata' at the very top).
reflect-metadata: reads decorator-emitted type metadata at runtime.
Build tooling
Follow these so decorator metadata exists at runtime:
- In
tsconfig.json:experimentalDecorators: true,emitDecoratorMetadata: true. - Load
reflect-metadatabefore DI executes (normally handled by@viewfly/core; otherwise import manually). - With Vite, add a pipeline that emits metadata (SWC or Babel recommended).
- Verify with the “minimal loop” example on this page.
Option A: Vite + SWC
Install:
npm install -D vite-plugin-swc-transformWire SWC in vite.config.ts with decorator metadata:
import { defineConfig, type Plugin } from 'vite'
import swc from 'vite-plugin-swc-transform'
export default defineConfig({
plugins: [
swc({
swcOptions: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
useDefineForClassFields: false,
},
},
},
}) as Plugin,
],
})Option B: Vite + Babel
If you already use Babel:
npm install -D @babel/core @babel/plugin-proposal-decorators babel-plugin-transform-typescript-metadata vite-plugin-babelExample vite.config.ts:
import { defineConfig } from 'vite'
import babel from 'vite-plugin-babel'
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
'babel-plugin-transform-typescript-metadata',
],
},
}),
],
})Minimal loop: define → register → use
Three roles, easier to maintain when separated:
- Define: name the dependency (class or token) so relationships are explicit and swappable.
- Register: say where instances come from (root or local providers) and centralize construction.
- Use: call
inject(...)from the component body for business logic only.
Why split “create” from “use”? Keeping both inside components grows files fast and blocks tests; separating assembly from usage improves reuse and testing.
import { Injectable, inject } from '@viewfly/core'
import { createApp } from '@viewfly/platform-browser'
// 1) Define: mark an injectable service
@Injectable()
class UserService {
getName() {
return 'Viewfly'
}
}
function Child() {
// 3) Use: resolve in the component body
const userService = inject(UserService)
return () => <p>{userService.getName()}</p>
}
createApp(<Child />)
// 2) Register: hand providers to the app before mount
.provide(UserService)
.mount(document.getElementById('app')!)Where to register: root vs local
Should this service be shared app-wide or scoped to a page/module?
- Global singleton-style sharing (config, loggers): root registration.
- Subtree-only visibility (feature-local state): local registration.
Root registration
When you do not want to repeat registration per page and the instance should survive route changes:
createApp(<App />)
.provide([AService, BService])
.mount(document.getElementById('app')!)Local registration
When only a subtree should see the service—avoid leaking providers tree-wide:
Wrap with createContext([...]); injections resolve only under that subtree.
import { createContext, inject, Injectable } from '@viewfly/core'
@Injectable()
class ThemeService {
mode = 'dark'
}
const ThemeContext = createContext([ThemeService])
function Child() {
const theme = inject(ThemeService)
return () => <p>{theme.mode}</p>
}
function App() {
return () => (
<ThemeContext>
<Child />
</ThemeContext>
)
}Short map:
- Root: default app-wide shared.
- Local: default subtree scoped.
Instance reuse rules
Reuse depends on whether resolution happens in the same injector:
- Same injector: first resolution caches; later resolves reuse.
- Child layer re-provides the same token: child wins (nearest provider).
- Parallel subtrees: identical provider configs usually yield different instances.
That is why root provide feels like an app singleton, while createContext behaves like a local singleton.
Local boundaries in the tree
1) withAnnotation
Use when a module host component should always carry a fixed set of providers.
import { withAnnotation } from '@viewfly/core'
export const FeatureHost = withAnnotation(
{ providers: [FeatureService] },
function FeatureHost(props: { children?: any }) {
return () => <>{props.children}</>
},
)Best when the DI boundary is fixed at component definition time.
2) createContext
Use when the same provider bundle should wrap different subtrees in multiple places.
import { createContext } from '@viewfly/core'
const ThemeContext = createContext([ThemeService])
function App() {
return () => (
<>
<ThemeContext><PageA /></ThemeContext>
<ThemeContext><PageB /></ThemeContext>
</>
)
}Best when you reuse one container shape everywhere.
3) createContextProvider
Use when you frequently override one token locally (e.g. swap logger impl) without repeating createContext([...]).
import { createContextProvider } from '@viewfly/core'
const LoggerProvider = createContextProvider({ provide: LoggerService })
function App() {
return () => (
<LoggerProvider useClass={ConsoleLoggerService}>
<Page />
</LoggerProvider>
)
}Best for single-token overrides.
Quick pick:
- Module host component →
withAnnotation - Reusable provider group →
createContext - Single-token local swap →
createContextProvider
Common provider shapes
app.provide(...) / createContext([...]) accept Provider or Provider[].
A Provider tells the container how to satisfy a dependency. Four frequent patterns:
1) Pass the class (TypeProvider)
When the class is the default implementation.
@Injectable()
class UserService {}
createApp(<App />)
.provide(UserService)
.mount(document.getElementById('app')!)The class acts as both token and useClass.
2) Fixed value (ValueProvider)
Constants, prebuilt objects, env maps.
import { InjectionToken } from '@viewfly/core'
const API_URL = new InjectionToken<string>('api-url')
createApp(<App />)
.provide({ provide: API_URL, useValue: '/api' })
.mount(document.getElementById('app')!)useValue skips instantiation.
3) Class binding (ClassProvider)
Expose a stable token while swapping implementations.
createApp(<App />)
.provide({
provide: LoggerService,
useClass: ConsoleLoggerService,
})
.mount(document.getElementById('app')!)Consumers keep calling inject(LoggerService); registration picks the concrete class.
4) Factory (FactoryProvider)
Construction needs parameters or branching.
createApp(<App />)
.provide({
provide: SessionService,
useFactory: () => new SessionService('guest'),
})
.mount(document.getElementById('app')!)Prefer useFactory when useClass is too rigid.
Combining providers
Real apps mix patterns:
import { InjectionToken } from '@viewfly/core'
const API_URL = new InjectionToken<string>('api-url')
createApp(<App />)
.provide([
UserService,
{ provide: API_URL, useValue: '/api' },
{ provide: LoggerService, useClass: ConsoleLoggerService },
{ provide: SessionService, useFactory: () => new SessionService('guest') },
])
.mount(document.getElementById('app')!)Within one Provider[] layer, later entries override earlier ones for the same token:
createApp(<App />)
.provide([
{ provide: LoggerService, useClass: ConsoleLoggerService },
{ provide: LoggerService, useClass: RemoteLoggerService }, // wins
])
.mount(document.getElementById('app')!)Where to call inject(...)
inject(...) must run in the component body, not inside render:
function Profile() {
const userService = inject(UserService)
return () => <p>{userService.getName()}</p>
}Troubleshooting
injectinside render — move it to the body.- Using before providers exist — ensure
provide/createContextwraps renders. - Tokens look equal but differ by reference — export and reuse one token module-wide.
- Business-domain isolation or dynamic mounting — see Dependency injection in depth.
Deeper rules
Advanced topics live in Dependency injection in depth:
- Full signature of
inject(token, notFoundValue?, flags?) depsordering and parameter decorators (@Inject,@Optional,@Self,@SkipSelf)forwardRef,@Prop, and domain isolation