Dependency injection vs context and modules
Angular's injector creates and scopes instances; React has none: a module for the app, a provider per subtree, a store for changing state.
Predict first
Before you read: predict
ExamHall's Angular candidate app shows a candidate's two attempts side by side on its review screen:
Once the review screen is on screen, how many service instances exist, counting both classes?
@Injectable({ providedIn: 'root' })class ExamSessionService { candidate = 'C-104'; } @Injectable()class AttemptStore { answers = new Map(); } @Component({ selector: 'attempt-panel', providers: [AttemptStore], template: '<p>{{ session.candidate }}: {{ attempt.answers.size }} answered</p>' })class AttemptPanel { attempt = inject(AttemptStore); session = inject(ExamSessionService); } @Component({ selector: 'review-screen', imports: [AttemptPanel], template: '<attempt-panel /><attempt-panel />' })class ReviewScreen {}The 30-second version
Angular builds services for you: providedIn: 'root' is one instance for the app; a component's providers give each instance of that component its own, found by walking up the tree. React has no container. A module holds the app-wide instance, a provider component scopes one per subtree, and a store read by a hook holds changing state.
Angular's injector creates and scopes instances; React has none: a module for the app, a provider per subtree, a store for changing state.
How it works
Step 1 of 13
inject(Token); the injector hands back an instance. Your code never calls new.Analogy: An aid agency's field mission
The agency runs one country office and sends out several field teams, each with its own supplies.
| At the aid mission | In the code |
|---|---|
| the country office's satellite link serves every team; there is one | providedIn: 'root' (step 3) |
| each field team carries its own medical kit, packed when the team is deployed and gone when it is stood down | a component's providers (step 3) |
| a team member needing supplies checks the team's kit first, then the country office; if nobody stocks them, the request is refused | lookup walks up, NG0201 (step 4) |
| on an exercise day the country office issues dummy supplies, but a team carrying its own real kit still uses it | TestBed swap, component providers win (step 5) |
| at a mission with no logistics officer, the shared kit is bolted to the office wall, and a team's kit is whatever its leader hands over | a module, a provider (steps 7–8) |
Breaks down when: a logistics officer packs a kit before anyone asks, while Angular builds an instance only at the first inject (step 2). Wrong inference: "every provided service exists from startup".
Contrasting case: an aid worker who brings their own torch from home. That is new in the component: no lookup, and no test can swap it.
In real terms: Angular creates, scopes and swaps instances through its injector tree; React has no injector, so you place each instance in a module, a provider or a store.
Examples, run for real
4.1 ExamHall: EXH-3580 and MR !3880
The React port of ExamSessionService (EXH-3580) put the attempt's answers in a module-level store, so the second attempt opened showing the first attempt's answers. MR !3880 keeps a module-level API client, reads the sitting through a store hook, and scopes answers with a provider (steps 7, 8, 11):
// EXH-3580, the port
export const attempt = createStore({ answers: {} });
// MR !3880
export const examApi = createExamApi(); // stateless, app-wide
export const sitting = createStore({ candidate: 'C-104' });
function AttemptProvider({ children }) {
const [store] = useState(() => createStore({ answers: {} }));
return <AttemptContext.Provider value={store}>{children}</AttemptContext.Provider>;
}
4.1 EXH-3580 (port, answers in a module-level store): attempt 1 shows "C-104: 1 answered"; attempt 2 opens showing "C-104: 1 answered" (attempt 1's answers)
4.1 MR !3880 (a provider creates the store per mounted attempt): attempt 1 shows "C-104: 1 answered"; attempt 2 opens showing "C-104: 0 answered" · createExamApi() ran 1 time
The module store outlives every attempt; the provider creates a fresh store per mounted attempt.
4.2 Payments ledger: swapping a client in a test
A refund button reads its client one of two ways, and a test wraps it in <LedgerApiContext.Provider value={fakeLedger}> (step 12):
const api = useContext(LedgerApiContext); // read from context
import { ledgerApi as api } from './ledger-api'; // imported from a module
4.2 test wraps the button in <LedgerApiContext.Provider value={fakeLedger}> and clicks Refund: read from context -> calls [fake tx-9]
4.2 the same test, the button imports ledgerApi from its module -> calls [live tx-9]
The provider reaches only the component that reads the context; the module import still calls the live client.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A payments ledger shows two accounts side by side under one status filter. The Angular version provides the filter on the page:
The React port (createStore returns { get, set, subscribe }; useStoreValue(store) reads it with useSyncExternalStore):
const ledgerFilter = createStore('all');
function AccountPanel({ account }) {
const status = useStoreValue(ledgerFilter);
return (
<section>
<button onClick={() => ledgerFilter.set('pending')}>Pending only</button>
<p>{account}: {status}</p>
</section>
);
}
function LedgerPage() {
return <><AccountPanel account="EUR" /><AccountPanel account="USD" /></>;
}
In each version, the user clicks Pending only in the EUR panel.
Which version's USD panel then shows pending?
@Injectable()class LedgerFilter { status = signal('all'); } @Component({ selector: 'account-panel', template: `<button (click)="filter.status.set('pending')">Pending only</button> <p>{{ account }}: {{ filter.status() }}</p>` })class AccountPanel { @Input() account = ''; filter = inject(LedgerFilter); } @Component({ selector: 'ledger-page', imports: [AccountPanel], providers: [LedgerFilter], template: '<account-panel account="EUR" /><account-panel account="USD" />' })class LedgerPage {}Question 2 of 7
A streaming app's play button calls a stream API. The Angular version, whose StreamApi is provided in the app's config, not in root:
The React port, whose main.jsx wraps the app in <StreamApiContext.Provider value={liveStreamApi}>:
const StreamApiContext = createContext(null);
function PlayButton() {
const { start } = useContext(StreamApiContext);
return <button onClick={() => start('ep-1')}>Play</button>;
}
A new test in each version creates PlayButton and clicks nothing. The Angular test calls TestBed.configureTestingModule({ providers: [] }); the React test renders <PlayButton /> with no provider around it.
Which test fails while creating PlayButton?
@Injectable()class StreamApi { start(id) { calls.push('live ' + id); } } @Component({ selector: 'play-button', template: `<button (click)="api.start('ep-1')">Play</button>` })class PlayButton { api = inject(StreamApi); }Question 3 of 7
A ticket site's seat price button. The Angular version provides the pricing client on the component itself:
The React port renders its own provider:
const PricingContext = createContext(null);
function SeatPrice() {
return (
<PricingContext.Provider value={livePricing}>
<PriceButton seat="B12" />
</PricingContext.Provider>
);
}
function PriceButton({ seat }) {
const api = useContext(PricingContext);
return <button onClick={() => api.quote(seat)}>Price</button>;
}
Each test swaps in fakePricing and clicks Price. The Angular test calls TestBed.configureTestingModule({ providers: [{ provide: PricingApi, useValue: fakePricing }] }); the React test renders <PricingContext.Provider value={fakePricing}><SeatPrice /></PricingContext.Provider>.
Which test's click reaches fakePricing?
@Injectable({ providedIn: 'root' })class PricingApi { quote(seat) { calls.push('live ' + seat); } } @Component({ selector: 'seat-price', providers: [PricingApi], template: `<button (click)="api.quote('B12')">Price</button>` })class SeatPrice { api = inject(PricingApi); }Question 4 of 7
An interviewer asks: "How do you do dependency injection in React?" The role is the React port of ExamHall's Angular app.
Write three sentences.
Question 5 of 7
The chat app's Angular UnreadCounts is providedIn: 'root', changes on every new message, and is swapped for fake counts in component tests. Its React port keeps this tree; Header and Sidebar each render an UnreadBadge showing the unread total, Thread shows no count, and the module chatSocket.onMessage(fn) calls fn for each new message:
Two messages arrive, the user opens the sidebar, then a third message arrives.
Where should the counts live, so that the sidebar's badge opens showing 2, the third message re-renders no component that neither shows nor holds the counts, and a test can show Header's badge with fake counts by wrapping Header in a provider?
function App() { const [sidebarOpen, setSidebarOpen] = useState(false); return ( <> <Header /> <button onClick={() => setSidebarOpen(true)}>Sidebar</button> {sidebarOpen && <Sidebar />} <Thread /> </> );}Question 6 of 7
A chat thread's reply box keeps a draft. The Angular version:
The React port:
const DraftContext = createContext(null);
function DraftProvider({ children }) {
const [store] = useState(() => createStore(''));
return <DraftContext.Provider value={store}>{children}</DraftContext.Provider>;
}
function ReplyBox() {
const draft = useContext(DraftContext);
const text = useStoreValue(draft);
return <><button onClick={() => draft.set('on my way')}>Type</button><p>{text}</p></>;
}
function ThreadView() {
const [open, setOpen] = useState(true);
return (
<>
<button onClick={() => setOpen(!open)}>Reply</button>
{open && <DraftProvider><ReplyBox /></DraftProvider>}
</>
);
}
In each version, the user clicks Type, then clicks Reply twice (the box closes and opens again).
Which version's reply box still shows on my way?
@Injectable()class DraftStore { text = signal(''); } @Component({ selector: 'reply-box', providers: [DraftStore], template: `<button (click)="draft.text.set('on my way')">Type</button><p>{{ draft.text() }}</p>` })class ReplyBox { draft = inject(DraftStore); } @Component({ selector: 'thread-view', imports: [ReplyBox], template: `<button (click)="open.set(!open())">Reply</button> @if (open()) { <reply-box /> }` })class ThreadView { open = signal(true); }Question 7 of 7
A payments app has an FX tab its users rarely open. Building the FX client opens a live rates feed. The Angular version:
The React port:
const FxContext = createContext(null);
function FxProvider({ children }) {
const [fx] = useState(() => new FxRatesClient());
return <FxContext.Provider value={fx}>{children}</FxContext.Provider>;
}
function StatementsPage() {
const [fxOpen] = useState(false);
return (
<>
<p>Statements</p>
{fxOpen && <FxProvider><FxPanel /></FxProvider>}
</>
);
}
FxPanel in the port reads useContext(FxContext), and FxRatesClient's constructor calls openRatesFeed() as in Angular. A user opens the statements page and never opens the FX tab.
Which version opens the rates feed?
@Injectable({ providedIn: 'root' })class FxRatesClient { constructor() { openRatesFeed(); } } @Component({ selector: 'fx-panel', template: '<p>FX</p>' })class FxPanel { fx = inject(FxRatesClient); } @Component({ selector: 'statements-page', imports: [FxPanel], template: '<p>Statements</p> @if (fxOpen()) { <fx-panel /> }' })class StatementsPage { fxOpen = signal(false); }Go deeper
Pro tip
- Before porting a service, write down who must get the same instance and whether a test must swap it; those two answers pick module, provider or store.
When: turning an Angular
@Injectableinto React code (step 13). Status: RAN (React 19.3.0, jsdom 30.1.1).
Sources
They support the teaching; no key is cited. Valid as of 2026-10-12 (Angular 22.2, React 19.3).
- angular.dev, Dependency injection: https://angular.dev/guide/di (
angular-di.txt), named by the curriculum row. Quoted: "The @Service decorator is an ergonomic shorthand for @Injectable({providedIn: 'root'})" and "makes this service available throughout your entire application as a singleton." (step 3) - angular.dev, Hierarchical injectors: https://angular.dev/guide/di/hierarchical-dependency-injection (
angular-hierarchical-di.txt). Quoted: "If the component's injector lacks the provider, it passes the request up to its parent component's ElementInjector." (step 4) "NullInjector always throws an error unless you use @Optional()" (step 4) "Providing the service at the component level ensures that every instance of the component gets a private instance of the service." (step 3) "When the component instance is destroyed, so is that service instance." (step 3) - angular.dev, Testing services: https://angular.dev/guide/testing/services (
angular-testing-services.txt). Quoted: "providers: [{provide: TaxCalculator, useValue: taxCalculatorStub}]" and "By default, TestBed provides the real implementations of these dependencies" (step 5) - react.dev, Passing Data Deeply with Context: https://react.dev/learn/passing-data-deeply-with-context (
react-passing-data-context.txt), named by the curriculum row. Quoted: "If you don’t provide the context, React will use the default value you’ve specified in the previous step." (step 9) - react.dev,
useContext: https://react.dev/reference/react/useContext (react-usecontext.txt). Quoted: "It is determined as the value passed to the closest SomeContext above the calling component in the tree. If there is no such provider, then the returned value will be the defaultValue you have passed to createContext" (steps 8–9) "You can pass any values via context, including objects and functions." (step 10)
Related lessons
- Read first: Context vs external stores
- Related: Dependency inversion and NestJS dependency injection
- Related: Signals and computed vs useState and derived values
- Related: Singletons through the module cache
- Related: The provider pattern: context plus a guarded hook
- Related: NestJS modules, custom providers and why DI makes testing cheap
Checked against the tools and docs as of 2026-10-12.