Dependency Injection in React

Loading

Dependency Injection (DI) is a design pattern that promotes loose coupling and easier testing by externalizing a component’s dependencies. While React doesn’t include a built-in DI system like Angular, there are several effective ways to implement DI patterns in React applications.

Core DI Techniques in React

1. Prop Injection (Most Basic Form)

function UserProfile({ userService, analyticsService }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getCurrentUser().then(user => {
      setUser(user);
      analyticsService.track('profile_view', { userId: user.id });
    });
  }, [userService, analyticsService]);

  return <div>{user?.name}</div>;
}

// Usage
<UserProfile 
  userService={userService} 
  analyticsService={analyticsService} 
/>

2. Context API (React’s Built-in Solution)

// Create context
const ServicesContext = createContext();

// Provider component
export function ServicesProvider({ services, children }) {
  return (
    <ServicesContext.Provider value={services}>
      {children}
    </ServicesContext.Provider>
  );
}

// Custom hook for consumption
export function useService(serviceName) {
  const services = useContext(ServicesContext);
  if (!services) throw new Error('Missing ServicesProvider');
  return services[serviceName];
}

// Usage in component
function UserProfile() {
  const userService = useService('userService');
  const analyticsService = useService('analyticsService');
  // ... same implementation as above
}

// App setup
const services = {
  userService: new UserService(),
  analyticsService: new AnalyticsService()
};

function App() {
  return (
    <ServicesProvider services={services}>
      <UserProfile />
    </ServicesProvider>
  );
}

3. Higher-Order Components (HOC)

function withServices(WrappedComponent, serviceNames) {
  return function WithServices(props) {
    const services = useContext(ServicesContext);
    if (!services) throw new Error('Missing ServicesProvider');

    const serviceProps = serviceNames.reduce((acc, name) => {
      acc[name] = services[name];
      return acc;
    }, {});

    return <WrappedComponent {...props} {...serviceProps} />;
  };
}

// Usage
const EnhancedProfile = withServices(UserProfile, ['userService', 'analyticsService']);

Advanced DI Patterns

1. Constructor Injection with Class Components

class UserProfile extends React.Component {
  constructor(props) {
    super(props);
    this.userService = props.userService;
    this.analyticsService = props.analyticsService;
  }

  componentDidMount() {
    this.userService.getCurrentUser().then(user => {
      this.setState({ user });
      this.analyticsService.track('profile_view');
    });
  }

  render() {
    return <div>{this.state?.user?.name}</div>;
  }
}

2. Function Factory Pattern

function createUserProfile({ userService, analyticsService }) {
  return function UserProfile() {
    const [user, setUser] = useState(null);

    useEffect(() => {
      userService.getCurrentUser().then(setUser);
    }, []);

    useEffect(() => {
      if (user) analyticsService.track('profile_view');
    }, [user]);

    return <div>{user?.name}</div>;
  };
}

// During app initialization
const UserProfile = createUserProfile({
  userService,
  analyticsService
});

// Usage in JSX
<UserProfile />

3. Dependency Injection Container

// di-container.js
class DIContainer {
  constructor() {
    this.services = {};
  }

  register(name, service) {
    this.services[name] = service;
  }

  get(name) {
    const service = this.services[name];
    if (!service) throw new Error(`Service ${name} not found`);
    return service;
  }
}

export const container = new DIContainer();

// app-setup.js
container.register('userService', new UserService());
container.register('analyticsService', new AnalyticsService());

// In components
function UserProfile() {
  const userService = container.get('userService');
  const analyticsService = container.get('analyticsService');
  // ... implementation
}

Testing with DI

One of the main benefits of DI is easier testing:

// Test setup
const mockUserService = {
  getCurrentUser: jest.fn().mockResolvedValue({ id: 1, name: 'Test User' })
};

const mockAnalyticsService = {
  track: jest.fn()
};

test('should load and display user', async () => {
  render(
    <ServicesProvider services={{
      userService: mockUserService,
      analyticsService: mockAnalyticsService
    }}>
      <UserProfile />
    </ServicesProvider>
  );

  await waitFor(() => {
    expect(screen.getByText('Test User')).toBeInTheDocument();
    expect(mockAnalyticsService.track).toHaveBeenCalledWith(
      'profile_view', 
      { userId: 1 }
    );
  });
});

Best Practices

  1. Type Safety: Use TypeScript interfaces for services
  2. Single Responsibility: Each service should have one purpose
  3. Avoid Singleton: Prefer creating new instances for tests
  4. Explicit Dependencies: Clearly declare all dependencies
  5. Layer Separation: Keep UI components unaware of service implementations

TypeScript Implementation

interface Services {
  userService: UserService;
  analyticsService: AnalyticsService;
  // ... other services
}

const ServicesContext = createContext<Services | null>(null);

export function useService<K extends keyof Services>(name: K): Services[K] {
  const services = useContext(ServicesContext);
  if (!services) throw new Error('Missing ServicesProvider');
  return services[name];
}

// Service interfaces
interface UserService {
  getCurrentUser(): Promise<{ id: number; name: string }>;
}

interface AnalyticsService {
  track(event: string, payload?: Record<string, unknown>): void;
}

// Component usage
function UserProfile() {
  const userService = useService('userService');
  const analyticsService = useService('analyticsService');
  // ... implementation
}

Real-World Use Cases

  1. API Services: Inject different API clients (production vs mock)
  2. Analytics: Swap analytics providers easily
  3. Feature Flags: Inject feature toggle services
  4. Internationalization: Inject translation services
  5. Configuration: Inject environment-specific configs

Comparison with Other Patterns

TechniqueProsCons
Prop InjectionSimple, explicitProp drilling in deep trees
Context APINo prop drillingLess explicit dependencies
HOCReusable service injectionAdds component layers
ContainerCentralized managementGlobal access can be abused

Dependency Injection in React provides a clean way to manage application dependencies while maintaining testability and flexibility. The Context API offers the most React-idiomatic approach, but other patterns may be better suited depending on your specific needs.

Leave a Reply

Your email address will not be published. Required fields are marked *