Skip to main content

Reduce Coupling: Dependency Injection in React

Coupling is the silent killer of maintainable codebases. A component that creates its own dependencies (services, contexts, API clients) is tightly coupled: change where services live, and the component breaks. Dependency injection inverts this: instead of a component creating what it needs, you pass dependencies to it. The component depends on an interface, not an implementation. The result: components become testable, swappable, and agnostic to how dependencies are wired. This article teaches you dependency injection patterns in React, from prop drilling to context to service containers.

What Is Coupling and Why It Matters

Coupling measures how much one module depends on the internals of another. High coupling breaks easily.

// HIGH COUPLING: UserCard creates its own dependencies
export function UserCard({ userId }) {
const user = useUserService(); // hardcoded service
const theme = useThemeService(); // hardcoded service
const auth = useAuthContext(); // hardcoded context

return (
<div className={theme.colors.card}>
User: {user.name}
</div>
);
}

To test UserCard, you must mock useUserService, useThemeService, and useAuthContext. Change where these services are provided, and UserCard breaks. Add a new dependency to UserCard, and every test breaks.

Low coupling inverts the dependency:

// LOW COUPLING: UserCard receives dependencies
export function UserCard({
user, // data dependency
theme, // style dependency
onUpdate, // callback dependency
}) {
return (
<div className={theme.colors.card}>
User: {user.name}
<button onClick={() => onUpdate(user.id)}>Update</button>
</div>
);
}

Now UserCard is a pure function of its inputs. To test it, you create dummy data and pass it in. To change how services work, you change the caller, not UserCard. Research shows loosely coupled code has 30% fewer bugs and 40% faster refactoring (Delamaro et al., 2025).

Dependency Injection Patterns in React

Pattern 1: Prop Drilling

The simplest pattern: pass dependencies as props. Works for small apps or shallow component trees.

// services/userService.js
export const userService = {
fetch: async (id) => { /* ... */ },
};

// pages/Dashboard.jsx
export default function Dashboard() {
return (
<UserList userService={userService}> // pass dependency
<UserCard userService={userService} /> // and to child
</UserList>
);
}

// components/UserCard.jsx
export function UserCard({ userService }) {
const [user, setUser] = useState(null);

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

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

Prop drilling is transparent but scales poorly. With 10 levels of nesting and 3 dependencies, you pass 30 props through components that don't use them. It becomes noisy and error-prone.

Pattern 2: React Context (Dependency Container)

Context solves prop drilling by providing a dependency container at the tree root. Components anywhere below can access dependencies without props.

// services/index.js
export const userService = { /* ... */ };
export const authService = { /* ... */ };
export const notificationService = { /* ... */ };

// contexts/ServiceContext.jsx
import { createContext, useContext } from 'react';
import * as services from '../services';

const ServiceContext = createContext(null);

export function ServiceProvider({ children, overrides = {} }) {
const value = { ...services, ...overrides };
return (
<ServiceContext.Provider value={value}>
{children}
</ServiceContext.Provider>
);
}

export function useServices() {
const services = useContext(ServiceContext);
if (!services) {
throw new Error('useServices must be called within ServiceProvider');
}
return services;
}

// app.jsx
export default function App() {
return (
<ServiceProvider>
<Dashboard />
</ServiceProvider>
);
}

// components/UserCard.jsx
import { useServices } from '../contexts/ServiceContext';

export function UserCard({ userId }) {
const { userService, notificationService } = useServices();
const [user, setUser] = useState(null);

useEffect(() => {
userService.fetch(userId)
.then(setUser)
.catch(err => notificationService.error(err.message));
}, [userId, userService, notificationService]);

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

Context eliminates prop drilling. Testing becomes easier: override services in the context for each test.

// UserCard.test.jsx
import { render } from '@testing-library/react';
import { ServiceProvider } from '../contexts/ServiceContext';
import { UserCard } from './UserCard';

describe('UserCard', () => {
it('fetches and displays user', async () => {
const mockUserService = {
fetch: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
};
const mockNotificationService = { error: jest.fn() };

const { getByText } = render(
<ServiceProvider overrides={{
userService: mockUserService,
notificationService: mockNotificationService,
}}>
<UserCard userId={1} />
</ServiceProvider>
);

expect(await getByText('Alice')).toBeInTheDocument();
});
});

Context works well for teams of 5–30 people, codebases of 20K–100K lines. It's built into React; no external dependencies. As your service layer grows (20+ services), context becomes a dumping ground. Consider splitting into multiple contexts:

// contexts/AuthContext.jsx
export const AuthContext = createContext(null);

// contexts/DataContext.jsx
export const DataContext = createContext(null);

// app.jsx
export default function App() {
return (
<AuthContext.Provider value={authService}>
<DataContext.Provider value={{ userService, orderService }}>
<Dashboard />
</DataContext.Provider>
</AuthContext.Provider>
);
}

Pattern 3: Service Container (Explicit Wiring)

For large, multi-team codebases (100+ developers), a service container makes dependencies and their wiring explicit.

// services/ServiceContainer.js
// Central place where all dependencies are registered and wired

class ServiceContainer {
constructor() {
this.services = {};
}

register(name, factory) {
// factory is a function that creates the service
this.services[name] = { factory, instance: null };
}

get(name) {
if (!this.services[name]) {
throw new Error(`Service '${name}' not registered`);
}

// Lazy instantiation: create on first access
if (!this.services[name].instance) {
this.services[name].instance = this.services[name].factory(this);
}

return this.services[name].instance;
}

reset() {
// For testing: clear all instances
Object.values(this.services).forEach(service => {
service.instance = null;
});
}
}

export const container = new ServiceContainer();

// Register services
container.register('db', () => new Database(process.env.DB_URL));
container.register('userRepository', (c) => new UserRepository(c.get('db')));
container.register('userService', (c) => new UserService(c.get('userRepository')));
container.register('authService', (c) => new AuthService(c.get('userService')));

// Usage in components
import { container } from '../services/ServiceContainer';

export function UserCard({ userId }) {
const userService = container.get('userService');
const [user, setUser] = useState(null);

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

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

// Testing
import { container } from '../services/ServiceContainer';

describe('UserCard', () => {
beforeEach(() => {
container.reset();
container.register('userService', () => ({
fetch: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));
});

it('displays user', async () => {
const { getByText } = render(<UserCard userId={1} />);
expect(await getByText('Alice')).toBeInTheDocument();
});
});

A service container makes dependency wiring explicit and testable. Every service knows what it depends on (declared in register). Adding a new service doesn't require passing props through components. Swapping implementations for testing is one line.

For teams using TypeScript, libraries like InversifyJS or tsyringe automate service container management:

// With tsyringe
import { injectable, inject, container } from 'tsyringe';

@injectable()
class UserService {
constructor(@inject('userRepository') private repo: UserRepository) {}
}

container.register('userService', { useClass: UserService });

Dependency Injection Anti-Patterns

Anti-pattern 1: Service Locator as a global variable.

// BAD: global service locator
export const services = {}; // magic global

export function UserCard() {
services.userService.fetch(); // hidden dependency
}

This hides dependencies: reading UserCard, you don't see it depends on services.userService. Use explicit parameters (props, context, or container injection).

Anti-pattern 2: Creating dependencies inside components.

// BAD: component creates service
export function UserCard() {
const userService = new UserService(); // hardcoded creation

return <div>{userService.fetch()}</div>;
}

This is tightly coupled. To test, you must mock the constructor. Use a container or context.

Anti-pattern 3: Passing the entire service container to components.

// BAD: pass whole container
export function UserCard({ services }) {
const userService = services.get('userService');
}

Components become opaque about what they depend on. Prefer passing specific dependencies or a narrow context.

Choosing a Pattern

PatternCouplingTestabilityComplexityBest For
Prop drillingLowEasyLowSmall apps, shallow trees
ContextLowModerateModerateMedium apps, shared dependencies
Service containerLowEasyHighLarge apps, multi-team codebases
Global service locatorHighHardLowLegacy apps (avoid)

Start with prop drilling for new apps. As components deepen, switch to context. For codebases over 50K lines with 10+ shared services, a service container is worth the setup cost.

Key Takeaways

  • Coupling measures how much modules depend on each other's internals. Low coupling scales better.
  • Dependency injection decouples: components receive dependencies rather than creating them.
  • Three patterns: prop drilling (simple), context (medium), service container (explicit).
  • Testability improves dramatically with loose coupling: you control what dependencies a component receives.
  • Avoid service locators; prefer explicit dependency declaration.

Frequently Asked Questions

Can I mix patterns (prop drilling + context)?

Yes. Pass infrastructure services via context, feature-specific data via props. This is common: auth and notifications come from context, user data comes as props to specific components.

Is dependency injection overkill for small apps?

Yes. Use prop drilling for apps under 5K lines. The overhead of a service container isn't worth it. Start simple; refactor to context when prop drilling gets noisy.

How do I handle circular service dependencies?

You shouldn't have them. If Service A needs B and B needs A, you have a domain modeling problem. Either merge them into one service, or extract a third service that both depend on. If you can't refactor, use lazy instantiation: delay creating one dependency until after the other is created.

Can I use Dependency Injection with Redux or Zustand?

Yes. Redux stores and Zustand hooks are just services. Register them in your container or provide them via context. The pattern is the same.

Does React Query eliminate the need for dependency injection?

React Query handles data fetching, caching, and synchronization. You still need dependency injection for non-query services: auth, notifications, analytics. Use both together: Query for data, DI for business logic.

Further Reading