React Integration

This guide shows how to integrate ts-ioc-container with React applications using Context and hooks. The integration provides a clean, declarative way to manage dependency injection scopes in your React component tree.

React integration consists of three main parts:

Scope Hierarchy in React

In frontend applications, we typically use these scope levels:

First, create the React integration utilities:

TypeScript src/di/ScopeContext.ts
// src/di/ScopeContext.ts
import { createContext, useContext } from 'react';
import type { IContainer } from 'ts-ioc-container';

export const ScopeContext = createContext<IContainer | null>(null);

export function useContainer(): IContainer {
const container = useContext(ScopeContext);
if (!container) {
  throw new Error('useContainer must be used within a ScopeContext.Provider');
}
return container;
}
// src/di/ScopeContext.ts
import { createContext, useContext } from 'react';
import type { IContainer } from 'ts-ioc-container';

export const ScopeContext = createContext<IContainer | null>(null);

export function useContainer(): IContainer {
const container = useContext(ScopeContext);
if (!container) {
  throw new Error('useContainer must be used within a ScopeContext.Provider');
}
return container;
}
TypeScript src/di/useInject.ts
// src/di/useInject.ts
import { useContainer } from './ScopeContext';
import type { constructor, DependencyKey } from 'ts-ioc-container';

/**
* Hook to resolve a dependency from the current scope
* @param key - The dependency key or class constructor
* @returns The resolved dependency instance
*/
export function useInject<T>(key: DependencyKey | constructor<T>): T {
const container = useContainer();
return container.resolve<T>(key);
}
// src/di/useInject.ts
import { useContainer } from './ScopeContext';
import type { constructor, DependencyKey } from 'ts-ioc-container';

/**
* Hook to resolve a dependency from the current scope
* @param key - The dependency key or class constructor
* @returns The resolved dependency instance
*/
export function useInject<T>(key: DependencyKey | constructor<T>): T {
const container = useContainer();
return container.resolve<T>(key);
}
tsx src/di/Scope.tsx
// src/di/Scope.tsx
import React, { useEffect, useMemo } from 'react';
import { ScopeContext, useContainer } from './ScopeContext';
import type { Tag } from 'ts-ioc-container';

interface ScopeProps {
tags: Tag[];
children: React.ReactNode;
}

/**
* Creates a child scope with the specified tags.
* The scope is automatically disposed when the component unmounts.
*/
export function Scope({ tags, children }: ScopeProps) {
const parentContainer = useContainer();

const childScope = useMemo(
  () => parentContainer.createScope({ tags }),
  [parentContainer, ...tags]
);

useEffect(() => {
  return () => {
    childScope.dispose();
  };
}, [childScope]);

return (
  <ScopeContext.Provider value={childScope}>
    {children}
  </ScopeContext.Provider>
);
}
// src/di/Scope.tsx
import React, { useEffect, useMemo } from 'react';
import { ScopeContext, useContainer } from './ScopeContext';
import type { Tag } from 'ts-ioc-container';

interface ScopeProps {
tags: Tag[];
children: React.ReactNode;
}

/**
* Creates a child scope with the specified tags.
* The scope is automatically disposed when the component unmounts.
*/
export function Scope({ tags, children }: ScopeProps) {
const parentContainer = useContainer();

const childScope = useMemo(
  () => parentContainer.createScope({ tags }),
  [parentContainer, ...tags]
);

useEffect(() => {
  return () => {
    childScope.dispose();
  };
}, [childScope]);

return (
  <ScopeContext.Provider value={childScope}>
    {children}
  </ScopeContext.Provider>
);
}
TypeScript src/di/index.ts
// src/di/index.ts
export { ScopeContext, useContainer } from './ScopeContext';
export { useInject } from './useInject';
export { Scope } from './Scope';
// src/di/index.ts
export { ScopeContext, useContainer } from './ScopeContext';
export { useInject } from './useInject';
export { Scope } from './Scope';

Create your application container with registrations scoped to different levels:

TypeScript src/di/container.ts
// src/di/container.ts
import 'reflect-metadata';
import {
Container,
Registration,
MetadataInjector,
singleton,
scope,
} from 'ts-ioc-container';
import { ApiClient } from '../services/ApiClient';
import { AuthService } from '../services/AuthService';
import { ThemeService } from '../services/ThemeService';
import { PageDataLoader } from '../services/PageDataLoader';
import { FormStateService } from '../services/FormStateService';

export function createAppContainer() {
return new Container(new MetadataInjector())
  .addRegistration(
    // Application-scoped services (singletons at root level)
    Registration.fromClass(ApiClient)
      .pipe(singleton())
      .to('IApiClient'),
    Registration.fromClass(AuthService)
      .pipe(singleton())
      .to('IAuthService'),
    Registration.fromClass(ThemeService)
      .pipe(singleton())
      .to('IThemeService'),
  )
  .addRegistration(
    // Page-scoped services (created fresh for each page)
    Registration.fromClass(PageDataLoader)
      .pipe(scope((s) => s.hasTag('page')))
      .pipe(singleton())
      .to('IPageDataLoader'),
  )
  .addRegistration(
    // Widget-scoped services (created fresh for each widget instance)
    Registration.fromClass(FormStateService)
      .pipe(scope((s) => s.hasTag('widget')))
      .to('IFormStateService'),
  );
}
// src/di/container.ts
import 'reflect-metadata';
import {
Container,
Registration,
MetadataInjector,
singleton,
scope,
} from 'ts-ioc-container';
import { ApiClient } from '../services/ApiClient';
import { AuthService } from '../services/AuthService';
import { ThemeService } from '../services/ThemeService';
import { PageDataLoader } from '../services/PageDataLoader';
import { FormStateService } from '../services/FormStateService';

export function createAppContainer() {
return new Container(new MetadataInjector())
  .addRegistration(
    // Application-scoped services (singletons at root level)
    Registration.fromClass(ApiClient)
      .pipe(singleton())
      .to('IApiClient'),
    Registration.fromClass(AuthService)
      .pipe(singleton())
      .to('IAuthService'),
    Registration.fromClass(ThemeService)
      .pipe(singleton())
      .to('IThemeService'),
  )
  .addRegistration(
    // Page-scoped services (created fresh for each page)
    Registration.fromClass(PageDataLoader)
      .pipe(scope((s) => s.hasTag('page')))
      .pipe(singleton())
      .to('IPageDataLoader'),
  )
  .addRegistration(
    // Widget-scoped services (created fresh for each widget instance)
    Registration.fromClass(FormStateService)
      .pipe(scope((s) => s.hasTag('widget')))
      .to('IFormStateService'),
  );
}

Wrap your application with the root scope provider:

tsx src/App.tsx
// src/App.tsx
import React, { useMemo } from 'react';
import { ScopeContext } from './di';
import { createAppContainer } from './di/container';
import { Router } from './Router';

export function App() {
const container = useMemo(() => createAppContainer(), []);

return (
  <ScopeContext.Provider value={container}>
    <Router />
  </ScopeContext.Provider>
);
}
// src/App.tsx
import React, { useMemo } from 'react';
import { ScopeContext } from './di';
import { createAppContainer } from './di/container';
import { Router } from './Router';

export function App() {
const container = useMemo(() => createAppContainer(), []);

return (
  <ScopeContext.Provider value={container}>
    <Router />
  </ScopeContext.Provider>
);
}

Create page-level scopes for page-specific services:

tsx src/pages/UserProfilePage.tsx
// src/pages/UserProfilePage.tsx
import React, { useEffect } from 'react';
import { Scope, useInject } from '../di';
import { UserWidget } from '../widgets/UserWidget';
import type { IPageDataLoader } from '../services/PageDataLoader';

function UserProfileContent() {
const dataLoader = useInject<IPageDataLoader>('IPageDataLoader');

useEffect(() => {
  dataLoader.loadUserProfile();
}, [dataLoader]);

return (
  <div className="user-profile-page">
    <h1>User Profile</h1>
    {/* Widgets get their own scope */}
    <Scope tags={['widget']}>
      <UserWidget />
    </Scope>
  </div>
);
}

export function UserProfilePage() {
return (
  <Scope tags={['page']}>
    <UserProfileContent />
  </Scope>
);
}
// src/pages/UserProfilePage.tsx
import React, { useEffect } from 'react';
import { Scope, useInject } from '../di';
import { UserWidget } from '../widgets/UserWidget';
import type { IPageDataLoader } from '../services/PageDataLoader';

function UserProfileContent() {
const dataLoader = useInject<IPageDataLoader>('IPageDataLoader');

useEffect(() => {
  dataLoader.loadUserProfile();
}, [dataLoader]);

return (
  <div className="user-profile-page">
    <h1>User Profile</h1>
    {/* Widgets get their own scope */}
    <Scope tags={['widget']}>
      <UserWidget />
    </Scope>
  </div>
);
}

export function UserProfilePage() {
return (
  <Scope tags={['page']}>
    <UserProfileContent />
  </Scope>
);
}

Widgets can have isolated scopes with their own service instances:

tsx src/widgets/UserWidget.tsx
// src/widgets/UserWidget.tsx
import React from 'react';
import { Scope, useInject } from '../di';
import type { IFormStateService } from '../services/FormStateService';

function EditUserForm() {
const formState = useInject<IFormStateService>('IFormStateService');

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  formState.submit();
};

return (
  <form onSubmit={handleSubmit}>
    <input
      value={formState.getValue('name')}
      onChange={(e) => formState.setValue('name', e.target.value)}
    />
    <button type="submit">Save</button>
  </form>
);
}

export function UserWidget() {
return (
  <div className="user-widget">
    {/* Each widget gets its own form state */}
    <Scope tags={['widget']}>
      <EditUserForm />
    </Scope>
  </div>
);
}
// src/widgets/UserWidget.tsx
import React from 'react';
import { Scope, useInject } from '../di';
import type { IFormStateService } from '../services/FormStateService';

function EditUserForm() {
const formState = useInject<IFormStateService>('IFormStateService');

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  formState.submit();
};

return (
  <form onSubmit={handleSubmit}>
    <input
      value={formState.getValue('name')}
      onChange={(e) => formState.setValue('name', e.target.value)}
    />
    <button type="submit">Save</button>
  </form>
);
}

export function UserWidget() {
return (
  <div className="user-widget">
    {/* Each widget gets its own form state */}
    <Scope tags={['widget']}>
      <EditUserForm />
    </Scope>
  </div>
);
}

For type-safe dependency injection, use tokens with useInject:

TypeScript src/di/tokens.ts
// src/di/tokens.ts
import { SingleToken } from 'ts-ioc-container';
import type { IApiClient } from '../services/ApiClient';
import type { IAuthService } from '../services/AuthService';
import type { IThemeService } from '../services/ThemeService';

export const ApiClientToken = new SingleToken<IApiClient>('IApiClient');
export const AuthServiceToken = new SingleToken<IAuthService>('IAuthService');
export const ThemeServiceToken = new SingleToken<IThemeService>('IThemeService');
// src/di/tokens.ts
import { SingleToken } from 'ts-ioc-container';
import type { IApiClient } from '../services/ApiClient';
import type { IAuthService } from '../services/AuthService';
import type { IThemeService } from '../services/ThemeService';

export const ApiClientToken = new SingleToken<IApiClient>('IApiClient');
export const AuthServiceToken = new SingleToken<IAuthService>('IAuthService');
export const ThemeServiceToken = new SingleToken<IThemeService>('IThemeService');
TypeScript src/hooks/useAuth.ts
// src/hooks/useAuth.ts
import { useInject } from '../di';
import { AuthServiceToken } from '../di/tokens';

export function useAuth() {
const authService = useInject(AuthServiceToken);

return {
  user: authService.getCurrentUser(),
  login: authService.login.bind(authService),
  logout: authService.logout.bind(authService),
  isAuthenticated: authService.isAuthenticated(),
};
}
// src/hooks/useAuth.ts
import { useInject } from '../di';
import { AuthServiceToken } from '../di/tokens';

export function useAuth() {
const authService = useInject(AuthServiceToken);

return {
  user: authService.getCurrentUser(),
  login: authService.login.bind(authService),
  logout: authService.logout.bind(authService),
  isAuthenticated: authService.isAuthenticated(),
};
}

Here’s a complete example showing the scope hierarchy in action:

tsx Complete Todo App Example
// Complete example: Todo application with scoped services
import React, { useMemo, useEffect, useState } from 'react';
import {
Container,
MetadataInjector,
Registration,
singleton,
scope,
inject,
} from 'ts-ioc-container';

// ============ DI Setup ============
import { createContext, useContext } from 'react';
import type { IContainer, constructor, DependencyKey } from 'ts-ioc-container';

const ScopeContext = createContext<IContainer | null>(null);

function useContainer(): IContainer {
const container = useContext(ScopeContext);
if (!container) throw new Error('No container found');
return container;
}

function useInject<T>(key: DependencyKey | constructor<T>): T {
return useContainer().resolve<T>(key);
}

function Scope({ tags, children }: { tags: string[]; children: React.ReactNode }) {
const parent = useContainer();
const scope = useMemo(() => parent.createScope({ tags }), [parent, ...tags]);
useEffect(() => () => scope.dispose(), [scope]);
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
}

// ============ Services ============
interface ITodoApi {
fetchTodos(): Promise<{ id: number; text: string }[]>;
}

class TodoApi implements ITodoApi {
async fetchTodos() {
  return [
    { id: 1, text: 'Learn ts-ioc-container' },
    { id: 2, text: 'Build awesome apps' },
  ];
}
}

interface ITodoPageService {
todos: { id: number; text: string }[];
loadTodos(): Promise<void>;
}

class TodoPageService implements ITodoPageService {
todos: { id: number; text: string }[] = [];

constructor(@inject('ITodoApi') private api: ITodoApi) {}

async loadTodos() {
  this.todos = await this.api.fetchTodos();
}
}

interface ITodoFormService {
draft: string;
setDraft(value: string): void;
}

class TodoFormService implements ITodoFormService {
draft = '';
setDraft(value: string) {
  this.draft = value;
}
}

// ============ Container ============
function createContainer() {
return new Container(new MetadataInjector())
  .addRegistration(
    Registration.fromClass(TodoApi).pipe(singleton()).to('ITodoApi')
  )
  .addRegistration(
    Registration.fromClass(TodoPageService)
      .pipe(scope((s) => s.hasTag('page')))
      .pipe(singleton())
      .to('ITodoPageService')
  )
  .addRegistration(
    Registration.fromClass(TodoFormService)
      .pipe(scope((s) => s.hasTag('widget')))
      .to('ITodoFormService')
  );
}

// ============ Components ============
function TodoForm() {
const formService = useInject<ITodoFormService>('ITodoFormService');
const [draft, setDraft] = useState(formService.draft);

return (
  <input
    value={draft}
    onChange={(e) => {
      setDraft(e.target.value);
      formService.setDraft(e.target.value);
    }}
    placeholder="New todo..."
  />
);
}

function TodoList() {
const pageService = useInject<ITodoPageService>('ITodoPageService');
const [todos, setTodos] = useState(pageService.todos);

useEffect(() => {
  pageService.loadTodos().then(() => setTodos([...pageService.todos]));
}, [pageService]);

return (
  <ul>
    {todos.map((todo) => (
      <li key={todo.id}>{todo.text}</li>
    ))}
  </ul>
);
}

function TodoPage() {
return (
  <Scope tags={['page']}>
    <h1>Todos</h1>
    <TodoList />
    <Scope tags={['widget']}>
      <TodoForm />
    </Scope>
  </Scope>
);
}

function App() {
const container = useMemo(() => createContainer(), []);

return (
  <ScopeContext.Provider value={container}>
    <TodoPage />
  </ScopeContext.Provider>
);
}
// Complete example: Todo application with scoped services
import React, { useMemo, useEffect, useState } from 'react';
import {
Container,
MetadataInjector,
Registration,
singleton,
scope,
inject,
} from 'ts-ioc-container';

// ============ DI Setup ============
import { createContext, useContext } from 'react';
import type { IContainer, constructor, DependencyKey } from 'ts-ioc-container';

const ScopeContext = createContext<IContainer | null>(null);

function useContainer(): IContainer {
const container = useContext(ScopeContext);
if (!container) throw new Error('No container found');
return container;
}

function useInject<T>(key: DependencyKey | constructor<T>): T {
return useContainer().resolve<T>(key);
}

function Scope({ tags, children }: { tags: string[]; children: React.ReactNode }) {
const parent = useContainer();
const scope = useMemo(() => parent.createScope({ tags }), [parent, ...tags]);
useEffect(() => () => scope.dispose(), [scope]);
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
}

// ============ Services ============
interface ITodoApi {
fetchTodos(): Promise<{ id: number; text: string }[]>;
}

class TodoApi implements ITodoApi {
async fetchTodos() {
  return [
    { id: 1, text: 'Learn ts-ioc-container' },
    { id: 2, text: 'Build awesome apps' },
  ];
}
}

interface ITodoPageService {
todos: { id: number; text: string }[];
loadTodos(): Promise<void>;
}

class TodoPageService implements ITodoPageService {
todos: { id: number; text: string }[] = [];

constructor(@inject('ITodoApi') private api: ITodoApi) {}

async loadTodos() {
  this.todos = await this.api.fetchTodos();
}
}

interface ITodoFormService {
draft: string;
setDraft(value: string): void;
}

class TodoFormService implements ITodoFormService {
draft = '';
setDraft(value: string) {
  this.draft = value;
}
}

// ============ Container ============
function createContainer() {
return new Container(new MetadataInjector())
  .addRegistration(
    Registration.fromClass(TodoApi).pipe(singleton()).to('ITodoApi')
  )
  .addRegistration(
    Registration.fromClass(TodoPageService)
      .pipe(scope((s) => s.hasTag('page')))
      .pipe(singleton())
      .to('ITodoPageService')
  )
  .addRegistration(
    Registration.fromClass(TodoFormService)
      .pipe(scope((s) => s.hasTag('widget')))
      .to('ITodoFormService')
  );
}

// ============ Components ============
function TodoForm() {
const formService = useInject<ITodoFormService>('ITodoFormService');
const [draft, setDraft] = useState(formService.draft);

return (
  <input
    value={draft}
    onChange={(e) => {
      setDraft(e.target.value);
      formService.setDraft(e.target.value);
    }}
    placeholder="New todo..."
  />
);
}

function TodoList() {
const pageService = useInject<ITodoPageService>('ITodoPageService');
const [todos, setTodos] = useState(pageService.todos);

useEffect(() => {
  pageService.loadTodos().then(() => setTodos([...pageService.todos]));
}, [pageService]);

return (
  <ul>
    {todos.map((todo) => (
      <li key={todo.id}>{todo.text}</li>
    ))}
  </ul>
);
}

function TodoPage() {
return (
  <Scope tags={['page']}>
    <h1>Todos</h1>
    <TodoList />
    <Scope tags={['widget']}>
      <TodoForm />
    </Scope>
  </Scope>
);
}

function App() {
const container = useMemo(() => createContainer(), []);

return (
  <ScopeContext.Provider value={container}>
    <TodoPage />
  </ScopeContext.Provider>
);
}

Scope Naming Convention

Use consistent scope names across your application:

ScopeTagUse Case
ApplicationapplicationGlobal singletons (API clients, auth, theme)
PagepagePage-level state and data loaders
WidgetwidgetComponent-local services and form state

Memory Management

Testing

When testing components, provide a mock container:

tsx Testing with mock container
// src/__tests__/TodoList.test.tsx
import { render, screen } from '@testing-library/react';
import { Container, MetadataInjector, Registration } from 'ts-ioc-container';
import { ScopeContext } from '../di';
import { TodoList } from '../components/TodoList';

test('renders todos', async () => {
const mockPageService = {
  todos: [{ id: 1, text: 'Test todo' }],
  loadTodos: jest.fn().mockResolvedValue(undefined),
};

const container = new Container(new MetadataInjector())
  .addRegistration(
    Registration.fromValue(mockPageService).to('ITodoPageService')
  );

render(
  <ScopeContext.Provider value={container}>
    <TodoList />
  </ScopeContext.Provider>
);

expect(await screen.findByText('Test todo')).toBeInTheDocument();
});
// src/__tests__/TodoList.test.tsx
import { render, screen } from '@testing-library/react';
import { Container, MetadataInjector, Registration } from 'ts-ioc-container';
import { ScopeContext } from '../di';
import { TodoList } from '../components/TodoList';

test('renders todos', async () => {
const mockPageService = {
  todos: [{ id: 1, text: 'Test todo' }],
  loadTodos: jest.fn().mockResolvedValue(undefined),
};

const container = new Container(new MetadataInjector())
  .addRegistration(
    Registration.fromValue(mockPageService).to('ITodoPageService')
  );

render(
  <ScopeContext.Provider value={container}>
    <TodoList />
  </ScopeContext.Provider>
);

expect(await screen.findByText('Test todo')).toBeInTheDocument();
});

Avoiding Common Pitfalls

  1. Don’t resolve in render - Call useInject at the top of your component, not inside render logic
  2. Don’t create containers in render - Use useMemo for container creation
  3. Don’t forget to dispose - The Scope component handles this, but if creating scopes manually, always dispose

ScopeContext

React context for the current IoC container scope.

const ScopeContext = createContext<IContainer | null>(null);

useContainer

Hook to get the current container from context.

function useContainer(): IContainer

Throws an error if used outside of a ScopeContext.Provider.

useInject

Hook to resolve a dependency from the current scope.

function useInject<T>(key: DependencyKey | constructor<T>): T

Scope

Component that creates a child scope with tags.

interface ScopeProps {
  tags: Tag[];
  children: React.ReactNode;
}

function Scope({ tags, children }: ScopeProps): JSX.Element