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 serveandng buildcover the whole lifecycle. – Signals (signal,computed,effect) are the core state primitive. –@forrequirestrack. Use a stable ID. –httpResourceturns a signal-driven URL into reactive loading, error and value states. –@deferlazy-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, thenng version. - Some TypeScript familiarity (classes, types, decorators).
- No API key: Open-Meteo is free for non-commercial use.
Step 1: The CLI
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
@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
<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
@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()
export class FavoriteButton {
city = input.required<string>();
saved = input(false);
toggled = output<string>();
toggle() { this.toggled.emit(this.city()); }
}
<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
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()
@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
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
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
@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`;
}
}
{{ 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
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
@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
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
setTimeoutorsubscribe. - 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
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
trackkey. NullInjectorError: HttpClient: addprovideHttpClient()toapp.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 ineffect, used sparingly. - Keep services as the single writers of shared state.
- Lazy-load routes and
@deferheavy 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:
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:
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.
httpResourcecancels stale requests automatically. With a manualsubscribe, useswitchMapor you’ll render the wrong city. - Empty results. The geocoding API omits
resultsentirely when nothing matches, so always use?.and show an “unknown city” state. - localStorage in SSR. If you enable server rendering later,
localStoragedoesn’t exist on the server. Guard it withisPlatformBrowserorafterNextRender. - Effects writing signals. It creates loops and confusing updates. Derive with
computed, or uselinkedSignalwhen 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.
httpResourcemakes data loading declarative.@deferand 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.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
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.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.