Angular Crash Course in 10 Minutes (Angular 22 + Real App)

2 views

Angular has reinvented itself. NgModules, Zone.js and *ngIf are giving way to standalone components, signals, built-in control flow and zoneless change detection, which makes Angular feel lighter than ever. This crash course builds one real project with Angular 22.2 (CLI 22.2.4): a Weather Explorer that searches cities, saves favourites and shows live forecasts from the free Open-Meteo API. It uses modern Angular only: standalone components, signals, @if/@for/@switch, input() and output(), inject(), httpResource, @defer, a functional HTTP interceptor, and OnPush zoneless change detection.

⚡ TL;DR – ng new, ng serve and ng build cover the whole lifecycle. – Signals (signal, computed, effect) are the core state primitive. – @for requires track. Use a stable ID. – httpResource turns a signal-driven URL into reactive loading, error and value states. – @defer lazy-loads parts of a template; functional interceptors handle cross-cutting HTTP concerns.

Before you start

  • Node.js 20+ and npm.
  • The Angular CLI: npm install -g @angular/cli, then ng version.
  • Some TypeScript familiarity (classes, types, decorators).
  • No API key: Open-Meteo is free for non-commercial use.

Step 1: The CLI

bash
ng new weather-explorer --style=css --ssr=false
cd weather-explorer
ng serve        # http://localhost:4200
ng generate component city-card

New projects are standalone and zoneless by default. There’s no AppModule to manage.

Step 2: Components and templates

ts
@Component({
  selector: 'app-city-card',
  template: `<article><h3>{{ name() }}</h3><p>{{ max() }}° / {{ min() }}°</p></article>`,
})
export class CityCard {
  name = input.required<string>();
  max = input(0);
  min = input(0);
}

A component is a class plus a template. Standalone components import what they use directly, through their own imports array.

Step 3: Data binding

html
<p>{{ city() }}</p>                                   <!-- interpolation -->
<img [src]="iconUrl()" [alt]="description()" />      <!-- property binding -->
<button (click)="refresh()">Refresh</button>           <!-- event binding -->
<input [value]="query()" (input)="query.set($any($event.target).value)" />

Square brackets push data into the DOM; parentheses listen for events. Signals are read by calling them, as in city().

Step 4: Control flow with @if, @for and @switch

html
@if (forecast.isLoading()) {
  <p>Loading…</p>
} @else if (forecast.error()) {
  <p role="alert">Could not load the forecast.</p>
} @else {
  <ul>
    @for (day of days(); track day.date) {
      <li>{{ day.date | date:'EEE' }} — {{ day.max }}°</li>
    } @empty {
      <li>No data.</li>
    }
  </ul>
}

@switch (units()) {
  @case ('F') { <span>Fahrenheit</span> }
  @default { <span>Celsius</span> }
}

The built-in control flow needs no imports and is faster than *ngIf/*ngFor. track is mandatory: it lets Angular reuse DOM nodes instead of rebuilding the list.

Step 5: input() and output()

ts
export class FavoriteButton {
  city = input.required<string>();
  saved = input(false);
  toggled = output<string>();
  toggle() { this.toggled.emit(this.city()); }
}
html
<app-favorite-button [city]="c.name" [saved]="favs.has(c.name)" (toggled)="favs.toggle($event)" />

Data flows down through inputs and events flow up through outputs. Signal inputs are read-only inside the child.

Step 6: Signals, computed and effect

ts
units = signal<'C' | 'F'>('C');
days = signal<Day[]>([]);
hottest = computed(() => Math.max(...this.days().map(d => d.max)));
constructor() {
  effect(() => localStorage.setItem('units', this.units()));
}

A signal holds a value and notifies its readers when it changes. computed derives values lazily and caches them. effect runs side effects. Avoid setting other signals inside an effect; prefer computed.

Step 7: Services and inject()

ts
@Injectable({ providedIn: 'root' })
export class FavoritesService {
  #list = signal<string[]>(JSON.parse(localStorage.getItem('fav') ?? '[]'));
  list = this.#list.asReadonly();
  has = (c: string) => this.#list().includes(c);
  toggle(c: string) {
    this.#list.update(l => l.includes(c) ? l.filter(x => x !== c) : [...l, c]);
    localStorage.setItem('fav', JSON.stringify(this.#list()));
  }
}

export class FavoritesPage { favs = inject(FavoritesService); }

providedIn: 'root' creates one shared instance across the app. Exposing a read-only signal keeps all writes going through the service’s methods.

Step 8: Routing and lazy loading

ts
export const routes: Routes = [
  { path: '', loadComponent: () => import('./search/search').then(m => m.Search) },
  { path: 'city/:name', loadComponent: () => import('./city/city').then(m => m.CityPage) },
  { path: 'favorites', loadComponent: () => import('./favorites/favorites').then(m => m.FavoritesPage) },
];

// app.config.ts
provideRouter(routes, withComponentInputBinding())

loadComponent splits each page into its own chunk. withComponentInputBinding() delivers route params straight into input()s: name = input.required<string>() in CityPage receives :name.

Step 9: HttpClient and reactive forms

ts
export class Search {
  #http = inject(HttpClient);
  form = new FormGroup({ city: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(2)] }) });
  results = signal<GeoResult[]>([]);
  submit() {
    if (this.form.invalid) return;
    this.#http.get<{ results?: GeoResult[] }>('https://geocoding-api.open-meteo.com/v1/search',
      { params: { name: this.form.controls.city.value, count: 5 } })
      .subscribe(r => this.results.set(r.results ?? []));
  }
}

Add provideHttpClient(withFetch()) in app.config.ts. Reactive forms keep validation logic in TypeScript, where it’s easy to test.

Step 10: Pipes, built-in and custom

ts
@Pipe({ name: 'temp' })
export class TempPipe implements PipeTransform {
  transform(c: number, units: 'C' | 'F' = 'C') {
    return units === 'F' ? `${Math.round(c * 9 / 5 + 32)}°F` : `${Math.round(c)}°C`;
  }
}
html
{{ day.max | temp: units() }}  {{ day.date | date:'mediumDate' }}

Pure pipes only re-run when their inputs change, so they’re cheaper than calling a method from the template.

Step 11: httpResource

ts
export class CityPage {
  name = input.required<string>();
  geo = httpResource<GeoResponse>(() =>
    `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(this.name())}&count=1`);
  place = computed(() => this.geo.value()?.results?.[0]);
  forecast = httpResource<ForecastResponse>(() => {
    const p = this.place();
    return p ? `https://api.open-meteo.com/v1/forecast?latitude=${p.latitude}&longitude=${p.longitude}&daily=temperature_2m_max,temperature_2m_min&timezone=auto` : undefined;
  });
}

When the signals inside the function change, httpResource refetches and cancels the stale request. Returning undefined means “don’t fetch yet”, which is how the chained request waits for the geocode. It exposes value(), isLoading(), error() and reload().

Step 12: @defer

html
@defer (on viewport) {
  <app-forecast-chart [days]="days()" />
} @placeholder {
  <div class="chart-skeleton"></div>
} @loading (minimum 300ms) {
  <p>Loading chart…</p>
}

The chart’s code isn’t downloaded until it scrolls into view. Other triggers include on idle, on interaction and when condition.

Step 13: A functional HTTP interceptor

ts
export const timingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = performance.now();
  return next(req).pipe(
    retry({ count: 2, delay: 500 }),
    finalize(() => console.debug(`${req.method} ${req.url} ${Math.round(performance.now() - start)}ms`)),
  );
};
// app.config.ts
provideHttpClient(withFetch(), withInterceptors([timingInterceptor]))

Interceptors handle cross-cutting concerns such as auth headers, retries, logging and error toasts in one place. Only retry idempotent requests like GET.

Step 14: OnPush and zoneless

Without Zone.js, Angular re-renders a component only when a signal it reads changes, an input changes, or a template event fires. In practice:

  • Store state in signals, not plain fields mutated inside setTimeout or subscribe.
  • Convert observables with toSignal() when the template needs them.
  • If a view doesn’t update, look for state that isn’t a signal.

Step 15: ng build

bash
ng build      # outputs dist/weather-explorer/browser

Production builds are optimised and tree-shaken, with per-route chunks. As with any SPA, configure your host to fall back to index.html for deep links.

Troubleshooting

  • The view doesn’t update after async work: the value isn’t a signal (a zoneless gotcha).
  • “NG0955 / track expression” warnings: use a unique, stable track key.
  • NullInjectorError: HttpClient: add provideHttpClient() to app.config.ts.
  • Route params are undefined: you’re missing withComponentInputBinding(), or the input name doesn’t match the param.
  • The pipe or component isn’t recognised: add it to the standalone component’s imports.

Best practices

  • Put state in signals, derived values in computed, and side effects in effect, used sparingly.
  • Keep services as the single writers of shared state.
  • Lazy-load routes and @defer heavy widgets.
  • One interceptor per concern.

Testing components and services

The CLI sets up a test runner for you, so run ng test. Signals make tests simple because you can set state and read derived values synchronously:

ts
describe('FavoritesService', () => {
  beforeEach(() => localStorage.clear());
  it('toggles a city', () => {
    const s = TestBed.inject(FavoritesService);
    s.toggle('London');
    expect(s.has('London')).toBe(true);
    s.toggle('London');
    expect(s.list()).toEqual([]);
  });
});

For HTTP, provide provideHttpClient() and provideHttpClientTesting(), then flush fake responses with HttpTestingController. No real network is involved:

ts
const http = TestBed.inject(HttpTestingController);
http.expectOne(r => r.url.includes('geocoding-api')).flush({ results: [{ name: 'London', latitude: 51.5, longitude: -0.12 }] });

Edge cases

  • Encoded route params. A city like “São Paulo” arrives encoded in the URL; the router decodes it, but encode it again (encodeURIComponent) when you build API URLs.
  • Rapid navigation. httpResource cancels stale requests automatically. With a manual subscribe, use switchMap or you’ll render the wrong city.
  • Empty results. The geocoding API omits results entirely when nothing matches, so always use ?. and show an “unknown city” state.
  • localStorage in SSR. If you enable server rendering later, localStorage doesn’t exist on the server. Guard it with isPlatformBrowser or afterNextRender.
  • Effects writing signals. It creates loops and confusing updates. Derive with computed, or use linkedSignal when a value must reset based on another signal.
  • Accessibility. Announce loading and error states with aria-live="polite", and keep buttons as real <button> elements, so keyboard and screen-reader users get the same app.

FAQ

Do I still need RxJS?

Less often. Signals cover state; RxJS remains great for complex event streams and HttpClient pipelines.

Is Angular 22 still standalone by default?

Yes. New projects use standalone components with no NgModules.

Official documentation

Key takeaways

  • Modern Angular is built from standalone components, signals and the new control flow.
  • httpResource makes data loading declarative.
  • @defer and lazy routes keep bundles small.
  • Zoneless plus OnPush is fast, as long as your state lives in signals.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.