Skip to content

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:

json
{
  "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:

  1. In tsconfig.json: experimentalDecorators: true, emitDecoratorMetadata: true.
  2. Load reflect-metadata before DI executes (normally handled by @viewfly/core; otherwise import manually).
  3. With Vite, add a pipeline that emits metadata (SWC or Babel recommended).
  4. Verify with the “minimal loop” example on this page.

Option A: Vite + SWC

Install:

bash
npm install -D vite-plugin-swc-transform

Wire SWC in vite.config.ts with decorator metadata:

ts
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:

bash
npm install -D @babel/core @babel/plugin-proposal-decorators babel-plugin-transform-typescript-metadata vite-plugin-babel

Example vite.config.ts:

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.

tsx
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:

tsx
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.

tsx
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.

tsx
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.

tsx
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([...]).

tsx
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.

tsx
@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.

tsx
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.

tsx
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.

tsx
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:

tsx
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:

tsx
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:

tsx
function Profile() {
  const userService = inject(UserService)
  return () => <p>{userService.getName()}</p>
}

Troubleshooting

  1. inject inside render — move it to the body.
  2. Using before providers exist — ensure provide / createContext wraps renders.
  3. Tokens look equal but differ by reference — export and reuse one token module-wide.
  4. 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?)
  • deps ordering and parameter decorators (@Inject, @Optional, @Self, @SkipSelf)
  • forwardRef, @Prop, and domain isolation

Next steps

MIT License