DEV Community

Cover image for Angular Dependency Injection vs React Context + Hooks — Who Does It Better?
Placide
Placide

Posted on

Angular Dependency Injection vs React Context + Hooks — Who Does It Better?

Sharing logic and state across an application is one of the most fundamental challenges in frontend development. Angular and React both solve it — but with dramatically different philosophies.
Angular uses a built-in Dependency Injection (DI) container. React uses Context + Hooks. If you've worked seriously with both, you know these aren't just different APIs — they represent two completely different mental models.

In this article we'll compare both approaches head to head with real code, honest trade-offs, and a clear recommendation for different scenarios.

The Problem They Both Solve

Imagine you have an AuthService that manages the current user. You need it in your navigation bar, your profile page, your settings page, and your API interceptor. How do you share it?

Without a solution you end up prop-drilling — passing data through layers of components that don't even need it. Both Angular DI and React Context + Hooks exist to eliminate this problem.

The React Approach — Context + Hooks

React's solution is explicit and compositional. You create a Context, wrap your component tree with a Provider, and consume it via a custom Hook.

Step 1 — Create the Context and Provider

// auth/auth.context.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

interface AuthState {
  user: User | null;
  login: (credentials: { email: string; password: string }) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  async function login(credentials: { email: string; password: string }) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    });
    const data = await response.json();
    setUser(data.user);
  }

  function logout() {
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Create a custom Hook

// auth/use-auth.hook.ts
export function useAuth(): AuthState {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

3 — Wrap your app

// main.tsx
root.render(
  <AuthProvider>
    <App />
  </AuthProvider>
);
Enter fullscreen mode Exit fullscreen mode

Step 4 — Consume anywhere

// components/NavBar.tsx
export function NavBar() {
  const { user, logout } = useAuth();
  return (
    <nav>
      <span>Welcome, {user?.name}</span>
      <button onClick={logout}>Sign out</button>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Clean, composable, and very JavaScript-native. The pattern is straightforward once you understand it.

Angular Approach — Dependency Injection

Angular's DI system works at the framework level. You declare a service, and Angular's injector makes it available wherever you need it — no manual wiring required

// auth/auth.service.ts
import { Service, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface User {
  id: string;
  name: string;
  email: string;
}

@Service()
export class AuthService {
  private http = inject(HttpClient);

  private _user = signal<User | null>(null);
  user = this._user.asReadonly();
  isLoggedIn = computed(() => this._user() !== null);

  async login(credentials: { email: string; password: string }) {
    const data = await firstValueFrom(
      this.http.post<{ user: User }>('/api/auth/login', credentials)
    );
    this._user.set(data.user);
  }

  logout() {
    this._user.set(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Consume anywhere, automatically

// components/nav.component.ts
@Component({
  selector: 'app-nav',
  standalone: true,
  template: `
    <nav>
      <span>Welcome, {{ auth.user()?.name }}</span>
      <button (click)="auth.logout()">Sign out</button>
    </nav>
  `
})
export class NavComponent {
  auth = inject(AuthService);
}
Enter fullscreen mode Exit fullscreen mode

No Provider wrapping. No context errors. No if (!context) throw. Angular's injector handles everything automatically.

The Scoped Injection Superpower

One of Angular DI's most underappreciated features is scoped injection — providing a service at different levels of your app with a single line of configuration.

// App-level — one instance shared everywhere (default)
@Service()
export class AuthService { }

// Route-level — fresh instance per route
const routes: Routes = [{
  path: 'dashboard',
  component: DashboardComponent,
  providers: [DashboardStateService]
}];

// Component-level — fresh instance per component
@Component({
  providers: [TabStateService]
})
export class TabsComponent { }
Enter fullscreen mode Exit fullscreen mode

Replicating this in React requires careful Context placement and memoization. In Angular it's one configuration option.

Testing — Where the Difference Really Shows

Testing in React

// Wrap with a mock provider in every test
function renderWithAuth(ui: ReactElement, mockUser?: User) {
  return render(
    <AuthContext.Provider value={{
      user: mockUser ?? null,
      login: jest.fn(),
      logout: jest.fn()
    }}>
      {ui}
    </AuthContext.Provider>
  );
}

test('shows username when logged in', () => {
  renderWithAuth(<NavBar />, { id: '1', name: 'Alice', email: 'alice@test.com' });
  expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Testing In Angular

// Swap the service with a mock in TestBed — no wrapping needed
TestBed.configureTestingModule({
  providers: [
    { provide: AuthService, useValue: { user: signal(mockUser) } }
  ]
});

const fixture = TestBed.createComponent(NavComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Welcome, Alice');
Enter fullscreen mode Exit fullscreen mode

Angular's DI makes mocking trivial. You swap one provider declaration and every component in the test gets the mock automatically.

The Provider Hell Problem

As React apps grow, Context nesting becomes a real issue:

// A real-world React app root — this is called "Provider Hell"
root.render(
  <QueryClientProvider client={queryClient}>
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          <RouterProvider router={router}>
            <App />
          </RouterProvider>
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  </QueryClientProvider>
);
Enter fullscreen mode Exit fullscreen mode

Every new piece of shared state adds another wrapper. It works — but it's verbose and requires careful ordering.

Angular has no equivalent problem. Services are registered at the framework level. Your main.ts stays clean regardless of how many services you have:

// Angular — no matter how many services you add
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(routes)
  ]
});
Enter fullscreen mode Exit fullscreen mode

When to Use Which

Choose React Context + Hooks when:

  • You're building a small to medium app
  • Your team is more comfortable with React's compositional model
  • You want maximum flexibility in how state is structured
  • You're building a library or shareable component system

Choose Angular DI when:

  • You're building a large enterprise application
  • You need fine-grained scoping across routes and components
  • Testability and mocking are top priorities
  • You want the framework to manage wiring automatically

React Context + Hooks and Angular DI solve the same problem — but they reflect the core philosophy of each framework.

React says: here are the primitives, you decide how to wire them.
Angular says: tell us what you need, we'll handle the wiring.

Neither is objectively better. But after using both in production, Angular DI scales more gracefully as your app and team grow — while React Context feels more at home in smaller, more flexible codebases.

The best developers understand both. Because the mental models transfer even when the syntax doesn't.

Top comments (0)