Angular NotesRohit’s interview study guide
Chapter 01

Angular Fundamentals

The mental model, how an Angular app boots, and what changed from NgModules to standalone.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#The Angular mental model

An Angular application is a tree of components. Each component is a TypeScript class (the state and the behaviour) paired with a template (the HTML it renders) and a little metadata (its selector, what its template uses, its styles). One component sits at the root; its template uses other components, whose templates use others, and so on down to the leaves. Everything you see on screen is owned by exactly one component in that tree.

Around the tree there are two pieces of machinery you don't write yourself:

  • Dependency injection (DI). Components don't new their services. They ask for them with inject(SomeService), and an injector hands over a shared instance. Injectors form their own tree: one for the whole application (filled from appConfig), plus one per component that declares providers. See The idea behind dependency injection.
  • Change detection. When your state changes, Angular updates the parts of the DOM that depend on it. You never touch the DOM to show a new value — you change the state, and the template's bindings are re-evaluated. In Angular 22 the trigger is mostly signals: a template that reads count() is re-rendered when count is set. See How change detection works.

A good mental model is a spreadsheet. The component's state is the input cells. Every {{ }} and [binding] in the template is a formula cell that reads them. You never type into a formula cell; you change an input cell and the spreadsheet recalculates the formulas that depend on it. Angular's job is to be the recalculation engine — and, because templates are compiled ahead of time, it knows exactly which DOM nodes each formula writes to.

counter.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+1</button>
  `,
})
export class Counter {
  protected readonly count = signal(0);

  increment() {
    this.count.update((c) => c + 1);
  }
}
What’s happening
  1. @Component tells the Angular compiler "this class is a component". At build time the compiler reads the metadata, parses the template and turns it into JavaScript instructions stored on the class (see The @Component decorator). Nothing is parsed in the browser.
  2. selector: 'app-counter' means: wherever another template contains <app-counter>, create a Counter there. That element becomes the component's host element.
  3. count = signal(0) is the state — an input cell. It's protected so the template can read it but code outside the class can't.
  4. {{ count() }} is a formula: the template calls the signal to read it, and by reading it the view subscribes to it. (click)="increment()" wires a DOM event to a method.
  5. On a click, increment() runs update(c => c + 1): count goes 0 → 1. Writing to a signal that a template read marks that view dirty and schedules a render. No DOM code anywhere.
AdvancedSpec proving the creation and update passes

Here is the spec that proves it — and shows when each part happens:

counter.spec.ts
const fixture = TestBed.createComponent(Counter);
const host: HTMLElement = fixture.nativeElement;
console.log(host.innerHTML);           // <p></p><button>+1</button>
await fixture.whenStable();
console.log(host.querySelector('p')!.textContent); // Count: 0

host.querySelector('button')!.click();
console.log(host.querySelector('p')!.textContent); // Count: 0   (not yet!)
await fixture.whenStable();
console.log(host.querySelector('p')!.textContent); // Count: 1
What’s happening
  1. TestBed.createComponent(Counter) instantiates the class and runs the template's creation pass: it creates the <p>, an empty text node inside it, and the <button>, and attaches the click listener. The bindings haven't run yet, so the <p> is empty — the real output was "<p></p><button>+1</button>".
  2. await fixture.whenStable() lets Angular run its first update pass: it evaluates count() and writes Count: 0 into the text node.
  3. button.click() runs increment() synchronously, so count is already 1. But the DOM still says Count: 0: in zoneless Angular 22, the write only schedules change detection.
  4. The second whenStable() lets the scheduled render run, and the text becomes Count: 1. That's the whole loop — event → state change → scheduled re-render — and you'll see it in every chapter.
AdvancedThe pieces of Angular at a glance

What the pieces are, in one line each:

PieceWhat it isChapter
Componentclass + template + metadata; one per host elementComponents
Template binding{{ }}, [prop], (event), [(two-way)]Interpolation
Directivebehaviour added to an existing elementWhat directives are
Pipea value transform used in templatesWhat pipes are
Service + DIshared logic and state, handed out by injectorsDependency Injection
Signalreactive state that templates and computed() trackSignals
Routermaps URLs to componentsRouting
AdvancedAngular vs React: framework vs library

Angular vs a library like React, in the terms interviewers use: Angular is a framework — it owns the app's structure, ships its own router, forms, HTTP client, DI container and test setup, and compiles templates ahead of time. React is a view library you assemble those things around. The trade-off is less choice for more consistency: two Angular codebases look much more alike than two React ones.

#How an Angular application starts

The browser doesn't know anything about Angular. It loads index.html, which contains an unknown element <app-root> and a <script> tag. That script is your compiled main.ts, and its only job is to call bootstrapApplication(App, appConfig) — "start Angular, using these providers, and render App into the page".

These are the exact files ng new generates in Angular 22 (CLI 22.2.2):

src/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>NgSandbox</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>
AdvancedGenerated src/main.ts
src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));
AdvancedGenerated app.config.ts
src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes)
  ]
};
AdvancedGenerated root component app.ts
src/app/app.ts
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  imports: [RouterOutlet],
  selector: 'app-root',
  styleUrl: './app.css',
  templateUrl: './app.html',
})
export class App {
  protected readonly title = signal('ng-sandbox');
}
AdvancedBoot sequence from index.html to first render

The generated app.html is a large placeholder page (the Angular logo and links) that ends with <h1>Hello, {{ title() }}</h1> in the middle and <router-outlet /> at the bottom. You delete the placeholder and keep the outlet.

① index.html <app-root> + main.js ② main.ts bootstrapApplication() ③ App injector appConfig.providers ④ Initializers awaited first ⑤ Find the host element querySelector('app-root'), clear it ⑥ new App() field inits + constructor ⑦ Render the template creation + update pass ⑧ Promise resolves with ApplicationRef <app-root ng-version="22.2.1">…
AdvancedVerifying the boot order in a Vitest spec

I verified the order with a real bootstrapApplication call in a Vitest spec (jsdom provides the document):

bootstrap.spec.ts
const log: string[] = [];
export const APP_TITLE = new InjectionToken<string>('APP_TITLE');

@Component({
  selector: 'app-root',
  template: `<h1>Hello, {{ title() }}</h1>`,
})
class App {
  protected readonly title = signal(inject(APP_TITLE));
  constructor() {
    const host = document.querySelector('app-root')!;
    log.push(`App constructor: host innerHTML = ${JSON.stringify(host.innerHTML)}`);
    afterNextRender(() => log.push(`afterNextRender: host innerHTML = ${JSON.stringify(host.innerHTML)}`));
  }
}

document.body.innerHTML = '<app-root>Loading…</app-root>';
const appRef: ApplicationRef = await bootstrapApplication(App, {
  providers: [
    provideBrowserGlobalErrorListeners(),
    { provide: APP_TITLE, useValue: 'ng-sandbox' },
    provideAppInitializer(() => { log.push('app initializer'); }),
  ],
});
log.push('bootstrapApplication promise resolved');
AdvancedBoot order output, step by step
Output
app initializer
App constructor: host innerHTML = ""
afterNextRender: host innerHTML = "<h1>Hello, ng-sandbox</h1>"
bootstrapApplication promise resolved
final DOM: <app-root ng-version="22.2.1"><h1>Hello, ng-sandbox</h1></app-root>
appRef.components.length = 1
What’s happening
  1. The browser parses index.html. <app-root> is just an unknown element — it renders its text content (Loading… here) and nothing else. Then the module script loads and bootstrapApplication(App, config) runs.
  2. Angular creates the platform (one per page) and then the application injector (an environment injector) from config.providers. Providers are only registered here; no service is created yet.
  3. App initializers run, and Angular waits for them if they return a promise or observable — that's why app initializer is logged first. This is where apps load runtime config before anything renders.
  4. Angular looks up App's selector in the document. It finds <app-root> and clears its content — the constructor already sees innerHTML = "", so the Loading… placeholder is gone. If nothing matches you get NG05104: The selector "app-root" did not match any elements.
  5. Angular calls App's factory, which runs the field initialisers (inject(APP_TITLE) resolves 'ng-sandbox' from the application injector) and then the constructor body. The instance exists, but its template hasn't rendered.
  6. The template renders into the host element: creation pass (create <h1> and its text node) then update pass (write Hello, ng-sandbox). afterNextRender fires after that first render. Angular also stamps ng-version="22.2.1" on the root host element.
  7. Only then does the promise resolve with the ApplicationRef — so code after await bootstrapApplication(...) sees a rendered page. appRef.components holds the one root ComponentRef, and appRef.destroy() tears the whole app down (the test showed <app-root> removed from the body).
AdvancedBootstrap failures: NG05104 and NG0201

What happens when bootstrapping fails? The two failures I reproduced:

Errors
NG05104: The selector "app-root" did not match any elements.
NG0201: No provider found for `InjectionToken APP_TITLE`. Source: Environment Injector.
AdvancedWhat the two bootstrap errors mean

The first means index.html and the root component's selector disagree (often after renaming the selector). The second means the root component injects something that nobody provided in appConfig. In both cases the promise rejects, the .catch((err) => console.error(err)) in main.ts logs it, and the page stays blank — the generated .catch is the reason you see the error at all, so don't delete it.

AdvancedPitfall: blank page after bootstrap

#The root component

The root component is the one component you hand to bootstrapApplication — App in a new project. Angular renders it into the matching element in index.html; every other component in the app is rendered inside it, because some template under it uses that component's selector. It's the root of the component tree in the same way the application injector is the root of the injector tree.

Nothing about the class itself is special. App is an ordinary standalone component; it's "root" only because of how it's used:

  • It's created by bootstrapApplication, not by a parent template. That's why its host element comes from index.html (matched with document.querySelector(selector)), while every other component's host element is created by its parent's template.
  • It's the only component that can't receive inputs from a parent — there is no parent. Its data comes from services, the router and the application config.
  • Its template is usually the app shell: header, navigation, and a <router-outlet /> where routed pages appear (see router-outlet and routerLink). Keep it thin; feature UI belongs in routed components.
  • Its host element gets ng-version (as in the output above). That's the attribute Angular DevTools and some tools look for to detect an Angular app.
TypeScript
const appRef = await bootstrapApplication(App, appConfig);
appRef.components.length;                       // 1
appRef.components[0].instance instanceof App;   // true
appRef.components[0].location.nativeElement;    // the <app-root> element
What’s happening
  1. bootstrapApplication resolves with an ApplicationRef — the handle to the running app.
  2. appRef.components is the list of bootstrapped root components; a normal app has exactly one (1 in the spec).
  3. Each entry is a ComponentRef. Its instance is the actual App object (instanceof App printed true), and location.nativeElement is the <app-root> element that was found in index.html.
  4. You rarely need this outside tests and micro-frontend setups, but it makes the point concrete: the root component is just the first ComponentRef Angular creates, attached directly to the application instead of to a parent view.
AdvancedOlder syntax: NgModule root component

#appConfig and application providers

appConfig is the second argument to bootstrapApplication. Its type is tiny — this is the whole interface in @angular/core 22:

core.d.ts (Angular 22.2)
interface ApplicationConfig {
    /**
     * List of providers that should be available to the root component and all its children.
     */
    providers: Array<Provider | EnvironmentProviders>;
}
AdvancedA realistic appConfig, provider by provider

Everything in providers goes into the application (environment) injector, the top of the injector tree. A service provided there is a singleton for the whole app: every component and service that injects it gets the same instance. It replaces the providers array of the old AppModule, and all of the framework's setup — router, HttpClient, animations, hydration, zone or zoneless change detection — is now done by calling provideX() functions here instead of importing XModules.

A realistic config, compiled in my Angular 22 sandbox:

app.config.ts
import {
  ApplicationConfig, InjectionToken, mergeApplicationConfig,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';

export const API_URL = new InjectionToken<string>('API_URL');

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withFetch()),
    { provide: API_URL, useValue: 'https://api.example.com' },
  ],
};

// e.g. an extra config for tests or for the server build
export const testConfig = mergeApplicationConfig(appConfig, {
  providers: [{ provide: API_URL, useValue: 'http://localhost:3000' }],
});
What’s happening
  1. provideBrowserGlobalErrorListeners() is in every new project. Its .d.ts says it adds listeners for the window's 'error' and 'unhandledrejection' events and forwards them to Angular's ErrorHandler, so errors outside Angular's own call stacks are reported the same way as errors in components.
  2. provideRouter(routes, withComponentInputBinding()) registers the router and its configuration. The withX() functions are features — optional behaviour you opt into, here binding route params to component inputs (see Route parameters as component inputs). Unused features are tree-shaken out of the bundle.
  3. provideHttpClient(withFetch()) makes HttpClient injectable everywhere (see Setting up HttpClient).
  4. { provide: API_URL, useValue: … } is a plain provider for your own token — the same object-literal syntax NgModules used (see InjectionToken).
  5. mergeApplicationConfig(a, b) concatenates the providers arrays left to right; the merged config above has 5 providers. When two providers use the same token, the last one wins, so inject(API_URL) returned http://localhost:3000 in the merged app. The CLI uses exactly this for SSR: app.config.server.ts merges server-only providers into appConfig.
AdvancedLazy provider creation proved in a spec

Providers are lazy. Registering a class in appConfig doesn't create it; the injector creates it the first time someone injects it, then caches it:

providers.spec.ts
@Injectable() class Analytics { constructor() { log.push('Analytics created'); } }
@Injectable() class Clock { constructor() { log.push('Clock created'); } now() { return 'noon'; } }

@Component({ selector: 'app-root', template: `<p>{{ api }} at {{ clock.now() }}</p>` })
class Root {
  protected readonly api = inject(API_URL);
  protected readonly clock = inject(Clock);
  constructor() { log.push('Root created'); }
}

const appRef = await bootstrapApplication(Root, {
  providers: [...appConfig.providers, Analytics, Clock],
});
console.log(log.join(' -> '));   // Clock created -> Root created
appRef.injector.get(Clock) === appRef.injector.get(Clock); // true
What’s happening
  1. Analytics and Clock are both registered in the application injector. Nothing is created at that point.
  2. Angular creates Root. Its field initialisers run before the constructor body, so inject(Clock) runs first: the injector has no Clock yet, creates one (Clock created), caches it and returns it.
  3. Then the constructor body logs Root created — hence the order Clock created -> Root created.
  4. Nobody ever injects Analytics, so its constructor never runs; the spec asserts that Analytics created is not in the log.
  5. appRef.injector.get(Clock) twice returns the cached instance both times (true): one instance per injector — that's what "singleton" means in Angular.
AdvancedRule of thumb: where to provide services

#Standalone components

A standalone component declares its own template dependencies in its own imports array, so it doesn't need to belong to an NgModule. Before standalone, a component couldn't stand on its own: it had to be declared in exactly one NgModule, and that module decided what the component's template could use. "Standalone" literally means "doesn't need a module to give it a compilation context".

In Angular 22 every component, directive and pipe is standalone by default. The generated App above has no standalone flag at all, and reflectComponentType(UserCard).isStandalone printed true for a component that never mentions it. The .d.ts for the flag now reads:

core.d.ts (Angular 22.2)
    /**
     * Set `standalone` to `false` if you want to import the directive into an NgModule.
     */
    standalone?: boolean;
AdvancedWhat standalone actually changes

So in 22 you write standalone: false only for the old style; writing standalone: true is allowed but redundant. In Angular 16 — the version techStatus uses — it was the other way round: components were NgModule-declared by default and you opted in with standalone: true (from memory; the default flipped in a later major).

What standalone actually changes:

  • Template scope is per component. The compiler resolves <app-avatar> in a template by looking only at that component's imports. Nothing leaks in from a module or a sibling.
  • The component is the unit of reuse. To use a component you import the component itself — not "the module that declares it".
  • Lazy loading needs no module. A route can lazy-load a single component with loadComponent: () => import('./admin') (see Lazy loading).
  • Bootstrapping needs no module. bootstrapApplication(App, appConfig) replaces AppModule + bootstrapModule.
  • Tests are simpler. TestBed.configureTestingModule({ imports: [UserProfile] }) brings the component and everything it imports; you don't recreate its module.

What doesn't change: the component class, the template syntax, DI, change detection and lifecycle hooks work exactly the same whether a component is standalone or not. Standalone is about how the compiler finds template dependencies and how the app is assembled, not how components run.

The two worlds also interoperate, which is how big apps migrate gradually. I compiled and ran this in Angular 22:

mixing.spec.ts
// A standalone component can be imported into an NgModule…
@Component({ selector: 'app-old-card', standalone: false, template: `<app-avatar name="Rohit" />` })
class OldCard {}
@NgModule({ declarations: [OldCard], imports: [Avatar], exports: [OldCard] })
class OldCardModule {}

// …and a standalone component can import an NgModule.
@Component({
  selector: 'app-new-page',
  imports: [OldCardModule, SharedModule],
  template: `<app-old-card /><app-avatar name="Legacy" />`,
})
class NewPage {}
AdvancedMixed standalone and NgModule rendering
Rendered
<app-old-card><app-avatar name="Rohit"><img src="/avatars/rohit.png" alt="Rohit"></app-avatar></app-old-card>
<app-avatar name="Legacy"><img src="/avatars/legacy.png" alt="Legacy"></app-avatar>
What’s happening
  1. OldCard is module-style (standalone: false), declared in OldCardModule. That module lists the standalone Avatar in its imports, so templates of components declared in the module — OldCard — can use <app-avatar>.
  2. NewPage is standalone and puts NgModules in its imports. Importing a module gives the template everything that module exports: <app-old-card> from OldCardModule, and from SharedModule the NgModule-declared AvatarComponent (whose selector is also app-avatar).
  3. Both renders worked: the avatar inside <app-old-card> is the standalone Avatar; the one directly in NewPage is SharedModule's AvatarComponent.
  4. The same selector meant two different classes in two templates, with no conflict. That's the per-component (or per-module) template scope in action: the compiler only matches selectors against what that template has imported.
AdvancedPitfall: NG6008 and other mixing errors

#NgModules: the old way

From my notes: in techStatus (Angular 16) we used the old way — an AppModule, feature modules and a shared module. An NgModule is a class with an @NgModule decorator that groups components, directives and pipes into a compilation context, and also carries providers. Every non-standalone component must be declared in exactly one module, and the module controls what its templates can see.

The four arrays are the part interviewers ask about:

FieldMeansExample
declarationscomponents/directives/pipes that belong to this module (each in exactly one module)[AppComponent]
importsother modules (or standalone components) whose exports this module's templates may use[BrowserModule, SharedModule]
exportswhat modules that import this module get to use[AvatarComponent]
providersservices registered in the injector this module contributes to[{ provide: GREETING, useValue: 'Hello' }]
bootstrap(root module only) components to render into index.html[AppComponent]

Here is the same small app both ways. The left side is the shape techStatus has; I compiled and ran it on Angular 22 (the only change needed was standalone: false, which Angular 16 didn't need because it was the default then). The right side is the modern equivalent.

NgModule (Angular 16 style)

app.module.ts
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, SharedModule],
  providers: [
    provideBrowserGlobalErrorListeners(),
    { provide: GREETING, useValue: 'Hello' },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

@NgModule({
  declarations: [AvatarComponent],
  imports: [CommonModule],
  exports: [AvatarComponent],
})
export class SharedModule {}
app.component.ts
@Component({
  selector: 'app-root',
  standalone: false, // needed in 22, implied in 16
  template: `
    <h1>{{ greeting }}, {{ user | uppercase }}</h1>
    <app-avatar [name]="user"></app-avatar>
  `,
})
export class AppComponent {
  user = 'Rohit';
  constructor(@Inject(GREETING) public greeting: string) {}
}
main.ts
// Angular 16 CLI generated:
// platformBrowserDynamic().bootstrapModule(AppModule)
platformBrowser()
  .bootstrapModule(AppModule)
  .catch((err) => console.error(err));

Standalone (Angular 22)

app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    { provide: GREETING, useValue: 'Hello' },
  ],
};
app.ts
@Component({
  selector: 'app-root',
  imports: [Avatar, UpperCasePipe],
  template: `
    <h1>{{ greeting }}, {{ user() | uppercase }}</h1>
    <app-avatar [name]="user()" />
  `,
})
export class App {
  protected readonly user = signal('Rohit');
  protected readonly greeting = inject(GREETING);
}
main.ts
bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));
AdvancedNgModule version rendered via bootstrapModule

Both render the same DOM. The module version's output, from platformBrowser().bootstrapModule(AppModule) in a Vitest spec:

Rendered (NgModule version)
<app-root ng-version="22.2.1"><h1>Hello, ROHIT</h1><app-avatar><img src="/avatars/rohit.png" alt="Rohit"></app-avatar></app-root>
What’s happening
  1. platformBrowser() creates the platform, and bootstrapModule(AppModule) compiles nothing at runtime — the module was compiled ahead of time — but it instantiates AppModule's injector with every provider from AppModule and from all the modules it imports, transitively (BrowserModule's, SharedModule's, …). That flattening is why a provider in any imported module ended up app-wide.
  2. For each component in bootstrap, Angular finds its selector in index.html and renders it — the same steps as bootstrapApplication from there on.
  3. AppComponent's template uses uppercase and <app-avatar>. The compiler found them through AppModule's compilation scope: uppercase because BrowserModule re-exports CommonModule, and app-avatar because SharedModule exports AvatarComponent. AppComponent itself says nothing about its dependencies.
  4. AvatarComponent's own template is compiled in SharedModule's scope (it's declared there), which is why SharedModule imports CommonModule itself — what AppModule imports doesn't reach it.
  5. In the standalone version every one of those relationships is written on the component that needs it: App imports Avatar and UpperCasePipe, providers live in appConfig, and bootstrapApplication replaces bootstrap: [...]. Nothing is implicit.
AdvancedWhy NgModules fell out of favour

The main.ts line deserves a note. Angular 16's CLI generated platformBrowserDynamic().bootstrapModule(AppModule) from @angular/platform-browser-dynamic, the package that could compile templates in the browser (JIT). With ahead-of-time compilation (the default since Ivy) that compiler was never needed in production. In Angular 22 an NgModule project generated with ng new --standalone=false uses platformBrowser().bootstrapModule(AppModule) from @angular/platform-browser, and npm marks @angular/platform-browser-dynamic@22.2.1 as deprecated: "Use @angular/platform-browser instead."

That generated 22 NgModule project also adds provideZoneChangeDetection({ eventCoalescing: true }) to AppModule's providers and keeps zone.js in package.json, because it was generated with zone-based change detection. In my spec, a module that didn't provide it ran with NoopNgZone — i.e. zoneless. techStatus loads zone.js, so its change detection works the old way (see zone.js and the old model).

Why NgModules fell out of favour:

  • Indirection. To know what a template can use you have to find its module, then every module that module imports, then what each of those exports. Errors like "not a known element" were often caused by a module three hops away.
  • Shared modules grow. A SharedModule that exports 40 components means every feature that imports it can use all 40, and tooling can't easily tell which ones it actually uses.
  • Lazy loading by module. Lazy routes had to load a whole feature module (loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)) even to show one page.
  • Provider surprises. Providers in eagerly-imported modules all land in the root injector; lazily-loaded modules get a child injector. The classic bug was a service provided in a shared module that was imported by a lazy module, producing a second instance.

NgModules are not deprecated in Angular 22 — the compiler accepted every module above, and ng new --standalone=false still generates one. You'll keep meeting them in existing apps and libraries. For a migration, ng generate @angular/core:standalone (alias standalone) is the schematic that converts an app step by step; it's listed in @angular/core's schematic collection in 22.

#The modern import model

From my notes: "component has to be imported in imports since you are using appAvatar in templates." That's the whole rule of the modern import model: every component, directive and pipe a template uses must be in that component's imports. There is no other source of template dependencies.

It helps to see that a standalone component has two different "imports", and they do different jobs:

profile-card.ts
import { Component, signal } from '@angular/core';
import { UpperCasePipe } from '@angular/common';   // ← TypeScript import
import { Avatar } from './avatar';                 // ← TypeScript import

@Component({
  selector: 'app-profile-card',
  imports: [Avatar, UpperCasePipe],                // ← template imports
  template: `
    <app-avatar [name]="user()" />
    <h2>{{ user() | uppercase }}</h2>
  `,
})
export class ProfileCard {
  protected readonly user = signal('Rohit');
}
AdvancedTypeScript import vs @Component.imports
avatar.ts
@Component({
  selector: 'app-avatar',
  template: `<img [src]="url()" [alt]="name()" />`,
})
export class Avatar {
  readonly name = input.required<string>();
  protected readonly url = computed(() => `/avatars/${this.name().toLowerCase()}.png`);
}
What’s happening
  1. The TypeScript import { Avatar } from './avatar' makes the symbol Avatar available in this file. It's a module-system thing; it says nothing about templates.
  2. imports: [Avatar, UpperCasePipe] in @Component tells the Angular compiler which selectors and pipe names this template may use. The compiler reads Avatar's compiled metadata, sees selector app-avatar and input name, and UpperCasePipe's name uppercase.
  3. While compiling the template, <app-avatar [name]="user()"> is matched against those imports: it creates an Avatar there and type-checks user() (a string) against the name input (input.required<string>()). {{ user() | uppercase }} resolves to UpperCasePipe.
  4. The rendered DOM, from the spec: <app-avatar><img src="/avatars/rohit.png" alt="Rohit"></app-avatar><h2>ROHIT</h2>.
  5. You need both: the TypeScript import so the identifier exists, and the imports entry so the template compiler knows about it. Your editor usually adds the first; the Angular language service offers to add the second.
AdvancedThree import mistakes in code

Here is what each mistake looks like — real ng build output from Angular 22.2 with strict templates on (the default for new projects):

errors.ts
// 1. TypeScript import present, template import missing
@Component({
  selector: 'app-profile-missing',
  template: `<app-avatar [name]="'Rohit'" /> {{ 'x' | uppercase }}`,
})
export class ProfileMissing {}

// 2. Imported but never used in the template
@Component({
  selector: 'app-profile-unused',
  imports: [Avatar],
  template: `<p>No avatar here</p>`,
})
export class ProfileUnused {}
AdvancedReal ng build errors: NG8001, NG8004, NG8113
ng build
✘ [ERROR] NG8001: 'app-avatar' is not a known element:
1. If 'app-avatar' is an Angular component, then verify that it is included in the '@Component.imports' of this component.
2. If 'app-avatar' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.

✘ [ERROR] NG8002: Can't bind to 'name' since it isn't a known property of 'app-avatar'.

✘ [ERROR] NG8004: No pipe found with name 'uppercase'.
To fix this, import the "UpperCasePipe" class from "@angular/common" and add it to the "imports" array of the component.

▲ [WARNING] NG8113: All imports are unused
What’s happening
  1. In the first component the TypeScript import of Avatar exists (TypeScript is happy — an unused import isn't an error), but the template compiler doesn't know app-avatar. It reports NG8001 for the element and, because it then can't know name is an input, NG8002 for the binding.
  2. Built-in pipes are no exception: uppercase needs UpperCasePipe in imports, hence NG8004. In NgModule apps CommonModule (re-exported by BrowserModule) made every common pipe and directive available implicitly, which is why this surprises people who migrate.
  3. The second component imports Avatar but never uses it. That's only a warning, NG8113, but worth fixing: unused imports keep code in the component's dependency list. The @angular/core:cleanup-unused-imports schematic removes them in bulk.
  4. The fix for all three is the same: put exactly what the template uses in imports, and nothing else.
AdvancedWhat can go in a component's imports

What can go in a component's imports:

  • Standalone components, directives and pipes — Avatar, RouterLink, DatePipe, NgOptimizedImage.
  • NgModules — ReactiveFormsModule, FormsModule, an old SharedModule. You get everything the module exports.
  • Arrays of the above — libraries often export a const array so you can import a group in one go (the type is (Type<any> | ReadonlyArray<any>)[]).

Not services. A service in imports is a mistake; services are provided (or @Service()/providedIn: 'root') and injected.

AdvancedModern note: dropping CommonModule

#Setting up an Angular project

Projects are created and driven by the Angular CLI (@angular/cli, command ng). Install it globally (npm install -g @angular/cli) or run it with npx.

bash
ng new my-app        # asks: stylesheet system? SSR/SSG? which AI tools?
cd my-app
ng serve             # dev server on http://localhost:4200, rebuilds on save
ng build             # production build into dist/my-app/browser
ng test              # unit tests with Vitest
ng generate component user-profile   # or: ng g c user-profile
AdvancedWhat ng new creates

ng new asks for the name and a few choices — the stylesheet system (CSS, Tailwind, SCSS, Sass, Less), whether to set up SSR/SSG, and which AI tools to generate config for (e.g. CLAUDE.md for Claude Code) — and then installs packages. The defaults in CLI 22, read from the schematic's schema.json: standalone true, zoneless true (no zone.js dependency), strict true, test runner vitest, SSR false, file names in the "2025" style (app.ts rather than app.component.ts), prefix app. Change them with flags, e.g. ng new legacy --standalone=false --zoneless=false --file-name-style-guide=2016 gives you an Angular-16-shaped NgModule project.

What a new project contains:

FileWhat it's for
angular.jsonWorkspace config: builders for build, serve, test; assets, global styles, budgets
package.jsonDependencies: @angular/core, common, compiler, forms, platform-browser, router, rxjs, tslib; dev: @angular/build, @angular/cli, @angular/compiler-cli, typescript ~6.0, vitest, jsdom
tsconfig.json (+ .app, .spec)TypeScript and Angular compiler options; app and test builds extend it
src/index.htmlThe single HTML page, containing <app-root>
src/main.tsEntry point: bootstrapApplication(App, appConfig)
src/styles.cssGlobal styles (not encapsulated)
src/app/app.ts / .html / .cssThe root component, its template and its styles
src/app/app.config.tsApplication providers
src/app/app.routes.tsexport const routes: Routes = [];
src/app/app.spec.tsTwo starter tests: creates the app, renders the title
public/Static files copied as-is (favicon.ico)

The build target uses the @angular/build:application builder (esbuild for bundling, Vite for the dev server). A production build of the untouched new project:

ng build
Initial chunk files | Names         |  Raw size | Estimated transfer size
main-5WLC77RJ.js    | main          | 216.47 kB |                59.39 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes

                    | Initial total | 216.47 kB |                59.39 kB

Application bundle generation complete. [1.740 seconds]

Output location: …/dist/ng-sandbox
What’s happening
  1. ng build runs the build target from angular.json, whose defaultConfiguration is production: optimisation on, outputHashing: "all" (hence main-5WLC77RJ.js, a content hash for cache-busting), and the budgets (warn above 500 kB initial, error above 1 MB).
  2. The Angular compiler type-checks every template (strict templates) and compiles them to instructions, then esbuild bundles and minifies the result. 216.47 kB raw is about 59 kB over the wire after compression — that's the whole framework plus the router for the starter app.
  3. The output goes to dist/ng-sandbox/browser/: index.html, the hashed main-*.js and styles-*.css, and favicon.ico from public/. The built index.html is your file with <link rel="stylesheet" href="styles-5INURTSO.css"> and <script src="main-5WLC77RJ.js" type="module"> injected.
  4. That folder is static files — deploy it to any static host and configure the host to serve index.html for unknown paths so client-side routes work on refresh.
Advancedng serve output in development mode

ng serve builds in the development configuration (no minification, source maps, no hashing) and serves from memory. I ran it with --port 4311 because 4200 is the default and was taken:

ng serve --port 4311
Initial chunk files | Names         | Raw size
main.js             | main          | 47.99 kB |
styles.css          | styles        | 95 bytes |

Watch mode enabled. Watching for file changes...
  ➜  Local:   http://localhost:4311/
AdvancedFiles from ng generate component

And ng generate component user-profile in CLI 22 creates four files in src/app/user-profile/:

user-profile.ts (generated)
import { Component } from '@angular/core';

@Component({
  imports: [],
  selector: 'app-user-profile',
  styleUrl: './user-profile.css',
  templateUrl: './user-profile.html',
})
export class UserProfile {}
What’s happening
  1. The class is UserProfile (no Component suffix) in user-profile.ts — the 2025 naming style. The template is user-profile.html containing <p>user-profile works!</p>, plus an empty user-profile.css and a user-profile.spec.ts.
  2. The selector is the project prefix plus the dasherised name: app-user-profile. Use it in another template — after adding UserProfile to that component's imports.
  3. There's no standalone flag (standalone is the default) and no changeDetection line: the schematic's changeDetection option defaults to "OnPush", which is also the framework default in 22, so there's nothing to write.
  4. The spec uses TestBed.configureTestingModule({ imports: [UserProfile] }) and await fixture.whenStable() — the zoneless testing pattern (see The Angular testing setup).
AdvancedOlder setup: Angular 16 project defaults
What's on the page, and when?

index.html contains <app-root>Loading…</app-root>. The root component's constructor logs host.innerHTML, and main.ts logs 'ready' after await bootstrapApplication(App, appConfig). In which order do you see: the Loading… text on screen, the constructor log (and what does it print), the rendered template, and 'ready'?

Show answer

Loading… first, then the constructor logs "", then the template renders, then 'ready'.

What’s happening
  1. Before any JavaScript runs, the browser shows <app-root>'s text content, Loading…. That's why a static placeholder in index.html works as a free splash screen.
  2. bootstrapApplication builds the injector, runs initializers, then finds <app-root> and clears its children before creating the component. So the constructor sees the host with innerHTML = "" — the placeholder is gone, and the template isn't rendered yet.
  3. After the constructor, Angular runs the template's creation and update passes, and the real content appears.
  4. Only after that first render does the bootstrap promise resolve, so 'ready' is logged last. In my spec the order was exactly: app initializer, App constructor: host innerHTML = "", afterNextRender: …<h1>Hello, ng-sandbox</h1>, bootstrapApplication promise resolved.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.