Skip to content

Frontend Frameworks

Frontend examples create scopes at page, route, screen, or feature boundaries. They keep their own small builders instead of importing the server-side shared module.

Compare where each framework creates the scope, how the scope is provided to children, and how cleanup runs on unmount.

ExampleShows
react.tsxReact feature scope with lazy useState and cleanup
react-native.tsxReact Native screen scope
vue.tsVue 3 provide/inject scope boundary
svelte.tsSvelte context scope boundary

React

tsx
import { Container } from '@inferdi/inferdi'
import {
  createContext,
  type PropsWithChildren,
  useContext,
  useEffect,
  useState,
} from 'react'

// Frontend examples keep their own minimal builder because the browser does
// not have the `Database` / `process.env` shape that `_shared/container.ts`
// uses on the server. The patterns are the same: scoped `FeatureContext`,
// singleton services on the root, page-level scope on mount.

class FeatureContext {
  featureName = ''
}

class ApiClient {
  async listProjects() {
    return [{ id: 'project_1', name: 'Docs' }]
  }
}

class ProjectsViewModel {
  constructor(
    private readonly feature: FeatureContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      feature: this.feature.featureName,
      projects: await this.api.listProjects(),
    }
  }
}

const root = new Container()
  .registerClass('feature', FeatureContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('projectsVm', ProjectsViewModel, ['feature', 'api'], 'scoped')

type AppContainer = typeof root
type PageContainer = ReturnType<typeof createProjectsPageScope>

function createProjectsPageScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('feature').featureName = 'projects'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIContext = createContext<AppContainer | null>(null)
const PageDIContext = createContext<PageContainer | null>(null)

export function AppDIProvider({ children }: PropsWithChildren) {
  return <RootDIContext.Provider value={root}>{children}</RootDIContext.Provider>
}

export function ProjectsPage({ children }: PropsWithChildren) {
  const parent = useContext(RootDIContext)
  if (parent === null) throw new Error('DI provider is missing')

  // useState with a lazy initializer is the correct primitive for "create
  // exactly once per mounted instance". `useMemo` is documented as an
  // optimization, not a guarantee — under concurrent rendering React may
  // re-run the factory at any time and the discarded scope would leak
  // because `useEffect` cleanup only runs on the kept render.
  const [scope] = useState(() => createProjectsPageScope(parent))

  useEffect(() => {
    // React cleanup is synchronous. Use async dispose explicitly so scopes
    // with async factories/disposers do not go through sync [Symbol.dispose]().
    return () => {
      scope.dispose().catch(console.error)
    }
  }, [scope])

  return <PageDIContext.Provider value={scope}>{children}</PageDIContext.Provider>
}

export function useProjectsViewModel() {
  const container = useContext(PageDIContext)
  if (container === null) throw new Error('Projects page scope is missing')
  return container.get('projectsVm')
}

Repository file: examples/frontend/react.tsx

React Native

tsx
import { Container } from '@inferdi/inferdi'
import {
  createContext,
  type PropsWithChildren,
  useContext,
  useEffect,
  useState,
} from 'react'

class ScreenContext {
  screenName = ''
}

class DeviceStorage {
  async getItem(key: string) {
    return `value:${key}`
  }
}

class SettingsViewModel {
  constructor(
    private readonly screen: ScreenContext,
    private readonly storage: DeviceStorage,
  ) {}

  async loadTheme() {
    return {
      screen: this.screen.screenName,
      theme: await this.storage.getItem('theme'),
    }
  }
}

const root = new Container()
  .registerClass('screen', ScreenContext, [], 'scoped')
  .registerClass('storage', DeviceStorage, [])
  .registerClass('settingsVm', SettingsViewModel, ['screen', 'storage'], 'scoped')

type AppContainer = typeof root
type ScreenContainer = ReturnType<typeof createSettingsScreenScope>

function createSettingsScreenScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('screen').screenName = 'settings'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIContext = createContext<AppContainer | null>(null)
const ScreenDIContext = createContext<ScreenContainer | null>(null)

export function AppDIProvider({ children }: PropsWithChildren) {
  return <RootDIContext.Provider value={root}>{children}</RootDIContext.Provider>
}

export function SettingsScreenScope({ children }: PropsWithChildren) {
  const parent = useContext(RootDIContext)
  if (parent === null) throw new Error('DI provider is missing')

  // useState with a lazy initializer (not useMemo) — see the React example
  // for the rationale: useMemo is not a guarantee, lazy useState is.
  const [scope] = useState(() => createSettingsScreenScope(parent))

  useEffect(() => {
    return () => {
      scope.dispose().catch(console.error)
    }
  }, [scope])

  return <ScreenDIContext.Provider value={scope}>{children}</ScreenDIContext.Provider>
}

export function useSettingsViewModel() {
  const container = useContext(ScreenDIContext)
  if (container === null) throw new Error('Settings screen scope is missing')
  return container.get('settingsVm')
}

Repository file: examples/frontend/react-native.tsx

Vue

ts
import { Container } from '@inferdi/inferdi'
import {
  defineComponent,
  inject,
  onUnmounted,
  provide,
  type InjectionKey,
} from 'vue'

class RouteContext {
  routeName = ''
}

class ApiClient {
  async dashboard() {
    return { total: 42 }
  }
}

class DashboardViewModel {
  constructor(
    private readonly route: RouteContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      route: this.route.routeName,
      dashboard: await this.api.dashboard(),
    }
  }
}

const root = new Container()
  .registerClass('route', RouteContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('dashboardVm', DashboardViewModel, ['route', 'api'], 'scoped')

type AppContainer = typeof root
type RouteContainer = ReturnType<typeof createDashboardRouteScope>

function createDashboardRouteScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('route').routeName = 'dashboard'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIKey: InjectionKey<AppContainer> = Symbol('InferDI.root')
const RouteDIKey: InjectionKey<RouteContainer> = Symbol('InferDI.route')

export const AppDIProvider = defineComponent({
  setup(_props, { slots }) {
    provide(RootDIKey, root)
    return () => slots.default?.()
  },
})

export const DashboardRouteProvider = defineComponent({
  setup(_props, { slots }) {
    const parent = inject(RootDIKey)
    if (parent === undefined) throw new Error('DI provider is missing')

    const scope = createDashboardRouteScope(parent)
    provide(RouteDIKey, scope)

    // Vue unmount hooks are synchronous; call async dispose explicitly.
    onUnmounted(() => {
      scope.dispose().catch(console.error)
    })

    return () => slots.default?.()
  },
})

export function useDashboardViewModel() {
  const container = inject(RouteDIKey)
  if (container === undefined) throw new Error('Dashboard route scope is missing')
  return container.get('dashboardVm')
}

Repository file: examples/frontend/vue.ts

Svelte

ts
import { Container } from '@inferdi/inferdi'
import { getContext, onDestroy, setContext } from 'svelte'

class RouteContext {
  routeName = ''
}

class ApiClient {
  async loadInbox() {
    return [{ id: 'msg_1', subject: 'Hello' }]
  }
}

class InboxViewModel {
  constructor(
    private readonly route: RouteContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      route: this.route.routeName,
      messages: await this.api.loadInbox(),
    }
  }
}

const root = new Container()
  .registerClass('route', RouteContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('inboxVm', InboxViewModel, ['route', 'api'], 'scoped')

type AppContainer = typeof root
type RouteContainer = ReturnType<typeof createInboxRouteScope>

const RootDIKey = Symbol('InferDI.root')
const RouteDIKey = Symbol('InferDI.route')

function createInboxRouteScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('route').routeName = 'inbox'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

export function provideRootContainer() {
  setContext(RootDIKey, root)
}

export function provideInboxRouteScope() {
  const parent = getContext<AppContainer | undefined>(RootDIKey)
  if (parent === undefined) {
    throw new Error('InferDI root context is missing — call provideRootContainer() in a parent layout')
  }
  const scope = createInboxRouteScope(parent)

  setContext(RouteDIKey, scope)

  // Svelte onDestroy is synchronous; start async cleanup and report failures.
  onDestroy(() => {
    scope.dispose().catch(console.error)
  })
}

export function getInboxViewModel() {
  const scope = getContext<RouteContainer | undefined>(RouteDIKey)
  if (scope === undefined) {
    throw new Error('Inbox route scope is missing — call provideInboxRouteScope() in this route')
  }
  return scope.get('inboxVm')
}

Repository file: examples/frontend/svelte.ts