DEV Community

张一凡
张一凡

Posted on

Dependency Injection in React: A Practical Guide

Bringing enterprise-grade architecture to frontend development

What is Dependency Injection?

Dependency Injection (DI) is a design pattern where dependencies are provided to a class from outside, rather than created internally.

// ❌ Without DI: tight coupling
class BlogService {
  private http = new AxiosHttp(); // Hard-coded
  async getPosts() {
    return this.http.get("/api/posts");
  }
}

// ✅ With DI: loose coupling
class BlogService {
  constructor(private http: HttpClient) {}
  async getPosts() {
    return this.http.get("/api/posts");
  }
}
Enter fullscreen mode Exit fullscreen mode

While common in backend development (Spring, .NET Core), DI is rarely discussed in frontend — until now.

Why DI in React?

1. Service Reuse Across Models

Without DI, you'd pass dependencies manually to every model:

// Tedious
const blogService = new BlogService(new AxiosHttp());
const userService = new UserService(new AxiosHttp());
const productService = new ProductService(new AxiosHttp());
Enter fullscreen mode Exit fullscreen mode

With DI, services are automatically injected:

class BlogModel {
  @inject(HttpSchema)
  private http?: HttpClient;

  async fetchPosts() {
    return this.http?.get("/api/posts");
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Environment Switching

Switch between mock and production APIs without code changes:

// Development
config(
  <Container>
    <CInjection schema={HttpSchema} ctor={MockHttp} />
  </Container>
);

// Production - just change one line
// <CInjection schema={HttpSchema} ctor={AxiosHttp} />
Enter fullscreen mode Exit fullscreen mode

3. Testability

Mock dependencies for unit testing:

describe('BlogModel', () => {
  beforeEach(() => {
    const mockHttp: HttpClient = {
      get: async (url: string) => {
        if (url.includes('posts')) {
          return [{ id: '1', title: 'Test Post' }];
        }
        return [];
      },
      post: async () => ({ id: 'new', title: 'New Post' }),
    };

    config(
      <Container>
        <CInjection schema={HttpSchema} ctor={mockHttp} />
      </Container>,
    );
  });

  test('fetches posts', async () => {
    const blog = provide(BlogModel)();
    await blog.fetchPosts();
    // Test passes with mock data
  });
});
Enter fullscreen mode Exit fullscreen mode

easy-model IoC: Architecture Overview

┌─────────────────────────────────────────┐
│                 App                      │
├─────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────────┐   │
│  │  BlogModel  │  │  UserModel      │   │
│  │  @inject    │  │  @inject        │   │
│  └──────┬──────┘  └────────┬────────┘   │
│         │                  │             │
│  ┌──────▼──────────────────▼────────┐   │
│  │         Container                 │   │
│  │  ┌─────────────────────────────┐  │   │
│  │  │  CInjection                 │  │   │
│  │  │  schema={HttpSchema}         │  │   │
│  │  │  ctor={MockHttp}            │  │   │
│  │  └─────────────────────────────┘  │   │
│  └───────────────────────────────────┘   │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Implementation

Step 1: Define Schema with Zod

// types/http.ts
import { z } from "zod";

export const HttpSchema = z.object({
  get: z.function().args(z.string()),
  post: z.function().args(z.string(), z.unknown()),
});

export type HttpClient = z.infer<typeof HttpSchema>;
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement Services

// http/mock-http.ts
export class MockHttp implements HttpClient {
  private database: Record<string, unknown[]> = {
    "/api/posts": [
      { id: "1", title: "Getting Started", content: "..." },
      { id: "2", title: "Advanced Topics", content: "..." },
    ],
  };

  async get(url: string) {
    return this.database[url] || [];
  }

  async post(url: string, data: unknown) {
    console.log("Mock POST:", url, data);
    return { id: crypto.randomUUID(), ...(data as object) };
  }
}

// http/axios-http.ts
export class AxiosHttp implements HttpClient {
  private client = axios.create({ baseURL: "" });

  async get(url: string) {
    const res = await this.client.get(url);
    return res.data;
  }

  async post(url: string, data: unknown) {
    const res = await this.client.post(url, data);
    return res.data;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Inject into Models

// models/blog.ts
import { inject, loader } from "@e7w/easy-model";
import { HttpSchema, type HttpClient } from "@/types/http";

export class BlogModel {
  posts: Post[] = [];

  @inject(HttpSchema)
  private http?: HttpClient;

  @loader.load(true)
  @loader.once
  async fetchPosts() {
    this.posts = (await this.http?.get("/api/posts")) as Post[];
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Configure Container

// main.tsx
import { CInjection, config, Container } from "@e7w/easy-model";
import { MockHttp } from "./http/mock-http";

config(
  <Container>
    <CInjection schema={HttpSchema} ctor={MockHttp} />
  </Container>
);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Project Structure

src/
├── types/
│   └── http.ts           # Schema definitions
├── http/
│   ├── mock-http.ts     # Mock implementation
│   └── axios-http.ts    # Axios implementation
├── models/
│   ├── blog.ts
│   ├── user.ts
│   └── comment.ts
├── ioc/
│   └── container.ts     # Container setup
└── main.tsx
Enter fullscreen mode Exit fullscreen mode

Namespace Isolation

For multiple environments or tenants:

import { CInjection, config, Container } from "@e7w/easy-model";
import { AdminAuth } from "./auth/admin";
import { UserAuth } from "./auth/user";

config(
  <>
    <Container namespace="admin">
      <CInjection schema={AuthSchema} ctor={AdminAuth} />
    </Container>
    <Container namespace="user">
      <CInjection schema={AuthSchema} ctor={UserAuth} />
    </Container>
  </>
);
Enter fullscreen mode Exit fullscreen mode

Then specify the namespace in your Model:

class DashboardModel {
  @inject(AuthSchema, "admin")
  adminAuth!: AuthService;

  @inject(AuthSchema, "user")
  userAuth!: AuthService;
}
Enter fullscreen mode Exit fullscreen mode

Testing Strategy

Unit Tests

describe('BlogModel', () => {
  let mockHttp: jest.Mocked<HttpClient>;

  beforeEach(() => {
    mockHttp = {
      get: jest.fn(),
      post: jest.fn(),
    } as any;

    config(
      <Container>
        <CInjection schema={HttpSchema} ctor={mockHttp} />
      </Container>,
    );
  });

  afterEach(() => {
    clearNamespace();
  });

  test('fetches posts successfully', async () => {
    const mockPosts = [{ id: '1', title: 'Test' }];
    mockHttp.get.mockResolvedValue(mockPosts);

    const { fetchPosts } = provide(BlogModel)();
    await fetchPosts();

    expect(mockHttp.get).toHaveBeenCalledWith('/api/posts');
  });
});
Enter fullscreen mode Exit fullscreen mode

Integration Tests

describe('BlogModel Integration', () => {
  beforeEach(() => {
    // Use real HTTP client for integration tests
    config(
      <Container>
        <CInjection schema={HttpSchema} ctor={AxiosHttp} />
      </Container>,
    );
  });

  test('fetches real data', async () => {
    const { fetchPosts } = provide(BlogModel)();
    await fetchPosts();
    // Tests hit real API
  });
});
Enter fullscreen mode Exit fullscreen mode

Comparison with Alternatives

Feature easy-model InversifyJS Manual DI
React Integration ✅ Native ❌ Adapter needed
Zod Support
Type Safety ✅ Full ✅ Full ⚠️ Partial
Learning Curve Low Medium Low
Boilerplate Minimal Medium Variable

When to Use DI

Use DI when:

  • Building medium to large applications
  • Multiple services need shared dependencies
  • Testing is a priority
  • Different environments require different implementations

Skip DI when:

  • Building simple prototypes
  • Services have minimal dependencies
  • Team is unfamiliar with DI patterns

Conclusion

Dependency injection brings backend engineering best practices to React development. With easy-model's IoC container, you get:

  • Service reuse without tight coupling
  • Environment switching with one-line configuration
  • Testability through easy mocking
  • Type safety with Zod schema validation

For enterprise React applications, these benefits significantly improve maintainability and code quality.

GitHub: https://github.com/ZYF93/easy-model


Have you implemented DI in frontend projects? Share your approach and experiences!

Top comments (0)