Skip to main content

Context API: Inversion of Control in React

React Context enables dependency injection: instead of passing adapters through dozens of component props, you provide them at the app root, and any component can access them. This pattern, called inversion of control, decouples components from specific implementations. Swap a real HTTP adapter for a mock in tests, or replace a localStorage adapter with a cloud storage one, all without touching component code. Context makes large applications easier to configure and test.

I've seen teams avoid Context because "it's for state management," but its real power is configuration and dependency injection. Your UI doesn't change; your app's behavior does. A UserContext providing a user adapter is not managing UI state; it is configuring which implementation components use. That distinction separates Context abuse (storing too much transient state) from Context mastery (providing infrastructure).

The Problem: Prop Drilling and Hard-Coded Dependencies

Without Context, you either pass adapters through every prop or hard-code them inside components. The first explodes with boilerplate; the second locks components to specific implementations.

Without Context (prop drilling):

// root
<App userAdapter={new UserHttpAdapter()} />

// Component A
function App({ userAdapter }) {
return <UserProfile userAdapter={userAdapter} />;
}

// Component B
function UserProfile({ userAdapter }) {
return <UserCard userAdapter={userAdapter} />;
}

// Component C (finally uses it)
function UserCard({ userAdapter }) {
const { user } = useUser(userId, userAdapter);
return <div>{user.name}</div>;
}

With three levels, props are manageable. With 10 levels, props become noise. Every component in the path must accept and pass the adapter, even if it doesn't use it.

Without Context (hard-coded dependencies):

// UserCard hard-codes the adapter
function UserCard({ userId }) {
const userAdapter = new UserHttpAdapter(); // Tightly coupled
const { user } = useUser(userId, userAdapter);
return <div>{user.name}</div>;
}

// In tests, you cannot mock the adapter
// You must mock fetch globally or live with real API calls

Hard-coded adapters are brittle: tests are slow (real HTTP calls), fragile (depends on server state), and you cannot test error paths easily.

Dependency Injection with Context

Context solves both: provide adapters at the root, components access them without prop drilling.

Step 1: Create a context.

import { createContext } from 'react';

// UserContext provides adapters related to users
export const UserContext = createContext(null);

// Export a hook for convenient access
export function useUserAdapter() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUserAdapter must be used within UserProvider');
}
return context;
}

Step 2: Create a provider component.

import { UserContext } from './UserContext';
import { UserHttpAdapter } from './adapters/UserHttpAdapter';

export function UserProvider({ children, adapter = new UserHttpAdapter() }) {
return (
<UserContext.Provider value={adapter}>
{children}
</UserContext.Provider>
);
}

Step 3: Wrap your app at the root.

import { UserProvider } from './contexts/UserProvider';

export function App() {
return (
<UserProvider>
<Router>
{/* all routes */}
</Router>
</UserProvider>
);
}

Step 4: Components access the adapter via the hook.

import { useUserAdapter } from './contexts/UserContext';
import { useUser } from './hooks/useUser';

function UserCard({ userId }) {
const userAdapter = useUserAdapter();
const { user } = useUser(userId, userAdapter);
return <div>{user.name}</div>;
}

No prop drilling. No hard-coded dependencies. The component is decoupled from the specific adapter implementation.

Composing Multiple Providers

Real apps need multiple adapters: user, post, comment, analytics, auth. Combine them in a custom root provider:

import { UserProvider } from './contexts/UserProvider';
import { PostProvider } from './contexts/PostProvider';
import { AuthProvider } from './contexts/AuthProvider';

export function AppProviders({ children }) {
return (
<AuthProvider>
<UserProvider>
<PostProvider>
{children}
</PostProvider>
</UserProvider>
</AuthProvider>
);
}

// In your app root
export function App() {
return (
<AppProviders>
<Router>
{/* routes */}
</Router>
</AppProviders>
);
}

Each provider passes a specific adapter or set of adapters. The composition is clean and testable.

Testing with Dependency Injection

The power of Context shines in tests:

import { render, screen } from '@testing-library/react';
import { UserProvider } from './contexts/UserProvider';
import { UserCard } from './UserCard';

it('displays user name from adapter', () => {
const mockAdapter = {
getById: jest.fn().mockResolvedValue({
id: 1,
name: 'John Doe',
}),
};

render(
<UserProvider adapter={mockAdapter}>
<UserCard userId={1} />
</UserProvider>
);

expect(screen.getByText('John Doe')).toBeInTheDocument();
});

it('displays error if adapter fails', async () => {
const mockAdapter = {
getById: jest.fn().mockRejectedValue(new Error('Network error')),
};

render(
<UserProvider adapter={mockAdapter}>
<UserCard userId={1} />
</UserProvider>
);

expect(await screen.findByText('Error: Network error')).toBeInTheDocument();
});

The adapter is injected at the provider level. No global mocks, no test pollution, each test is isolated. Swapping adapters is a one-line change.

Multiple Adapters in One Context

A context can provide multiple related adapters:

export const DataContext = createContext(null);

export function DataProvider({
children,
userAdapter = new UserHttpAdapter(),
postAdapter = new PostHttpAdapter()
}) {
const value = { userAdapter, postAdapter };
return (
<DataContext.Provider value={value}>
{children}
</DataContext.Provider>
);
}

export function useDataAdapters() {
const context = useContext(DataContext);
if (!context) {
throw new Error('useDataAdapters must be within DataProvider');
}
return context;
}

// Component usage
function Dashboard() {
const { userAdapter, postAdapter } = useDataAdapters();
const user = useUser(userId, userAdapter);
const posts = usePosts(postAdapter);
return <div>...</div>;
}

Environment-Based Configuration

Use Context to configure different adapters per environment:

export function AppProviders({ children, env = process.env.REACT_APP_ENV }) {
let userAdapter;

if (env === 'test') {
userAdapter = new MockUserAdapter();
} else if (env === 'development') {
userAdapter = new UserHttpAdapter({ baseURL: 'http://localhost:3000' });
} else {
userAdapter = new UserHttpAdapter({ baseURL: 'https://api.example.com' });
}

return (
<UserProvider adapter={userAdapter}>
{children}
</UserProvider>
);
}

The entire app adapts to the environment by swapping adapters at the root. Components never know the difference.

Key Takeaways

  • Dependency injection via Context decouples components from specific implementations.
  • Create a context and a provider for each domain (User, Post, Auth); export a hook for convenient access.
  • Compose multiple providers at the app root for clean initialization.
  • Pass mock adapters to providers in tests; no global mocks needed.
  • Configure different adapters per environment (test, dev, prod) at the provider level.

Frequently Asked Questions

Is Context the same as state management?

No. Context is a transport mechanism; state management is what state you store. You can use Context to inject adapters (infrastructure) or to share transient UI state (selected filter, sidebar open). The former is clean; the latter often leads to performance issues. Only use Context for state that is read frequently and by many components.

Should I use Context instead of props?

No. Use props for data that flows down a tree. Use Context for infrastructure (adapters, services) that would require 10+ levels of prop drilling. If you can prop-drill reasonably, do it; it is explicit and testable.

How do I avoid Context re-renders?

Memoize the Context value if it doesn't change frequently:

const value = useMemo(() => ({ adapter }), [adapter]);
return <Context.Provider value={value}>{children}</Context.Provider>;

If the context provides mutable adapters (which is typical), this is usually safe.

Can I nest contexts in a specific order?

Order rarely matters unless one context depends on another. For example, AuthProvider should wrap others if they need the current user. Keep dependency order logical; avoid circular dependencies.

Further Reading