When Angular Breaks
Angular fails loudly and bureaucratically: errors arrive with NG-numbers, long names, and - once you can read them - unusually precise diagnoses. That's the good news: where our Vue and Svelte phases hunted silent failures, most Angular failures announce themselves. The skill is translation, and the translations cluster around the same handful of causes.
The cheat-card
| Symptom / message | Almost always means | Fix |
|---|---|---|
| NG8001: 'app-x' is not a known element | Component used in a template but not in the imports array |
Add it to the standalone component's imports |
| NG0203: inject() must be called from an injection context | inject() in a handler, timeout, or lifecycle body |
Move to a field initializer or constructor (phase 5) |
| NullInjectorError: No provider for X | Service/feature never registered | providedIn: 'root' on the service, or the provideX() bootstrap call (e.g. provideHttpClient()) |
| Signal write error inside effect/computed | Deriving state with an effect, or mutating inside computed |
It's a computed (phase 3); keep derivations pure |
Template shows () => ... or [object Object] |
Interpolating the signal/observable itself | Call the signal ({{ x() }}); pipe the observable ({{ x$ | async }}) |
{{ title() }} throws "title is not a function" |
Calling a legacy decorator input like a signal | Check the dialect: @Input() fields read without parens (phase 4) |
| Screen updates late or only after clicking elsewhere | Mutating an object/array in place, or zone-era code paths | Return fresh references from update (phase 3) |
| Rows shuffle state after delete/reorder | track $index on a mutable list |
track item.id (phase 2) |
| Callbacks firing on destroyed components | Manual subscribe without a lifetime |
toSignal/async pipe, or takeUntilDestroyed (phase 6) |
| ExpressionChangedAfterItHasBeenCheckedError | Legacy zone-era code changing state mid-check | In new code, move the write out of rendering paths; in old code, see below |
Three of these deserve the walk-through.
NG8001: the standalone tax
NG8001: 'app-product-card' is not a known element
The single most common error in modern Angular, and it's a feature wearing an error's clothing. Standalone components declare their template dependencies explicitly:
Why it exists: in the NgModule era, anything declared in the module was visible to every
component in it - convenient, and impossible to tell what any single component actually used.
Standalone flips that: each component's imports array is its complete dependency list. The error
is the compiler asking you to finish the sentence. Same cure for pipes and directives
(imports: [DatePipe, FormsModule]) - if the template uses it, the component imports it.
The dialect mismatch pair
Phase 4 planted the flag; here's how it looks when it goes off. Two mirror-image errors:
title is not a function- template says{{ title() }}, but this component declares@Input() title: string. Legacy inputs are plain fields: drop the parens.[object Object]or a function body on screen - template says{{ count }}, butcountis a signal. Interpolating the signal object prints it; calling it reads it:{{ count() }}.
Neither is subtle once you know the tell: before editing any component, check its dialect -
signal functions (input(), signal()) mean parens in the template; decorators (@Input()) mean
none. Mixed teams migrating incrementally hit these weekly, and they're ten-second fixes with the
habit installed.
The legacy ghost: ExpressionChangedAfterItHasBeenCheckedError
You'll meet this one in older codebases, and its infamy is why it earns a section even in a modern guide. Zone-era Angular checked every binding after every event - then, in development mode, checked again to verify nothing changed mid-check. Code that wrote state during rendering (a getter with a side effect, a child mutating a parent's value during init) failed that second check and produced this error - Angular's bluntest statement of the universal rule that rendering must not change state.
In signal-era code the same sin surfaces earlier and clearer (the signal-write guards from phase 3). If you hit the legacy error in old code: find the write that happens during change detection - the error message names the binding - and move it into an event handler, lifecycle hook, or effect. The bug is always the write's timing, never the value.
Reading NG errors like a local
- The number is a documentation key. Every NG-code has a page at
angular.dev/errors/NG8001(swap the code) with causes and fixes - among the best error docs in the industry. Paste the code before pasting the stack trace into a search engine. - Read the template position. Compile-time errors point at file, line, and column in the template - the answer is usually at that exact spot, not in the TypeScript.
- Trust the compiler's strictness. A large share of "Angular is fighting me" moments are the
type system catching a real mismatch early -
input.requiredmissing, a wrong payload type on an output. The fight is the feature.
Recap
- NG8001 = the template uses something the component didn't import - standalone components list their dependencies, completely.
- NG0203 =
inject()outside construction; NullInjectorError = nothing registered - both are DI's rules, not mysteries. - Dialect tells: signals read with parens, decorator inputs without - check before editing.
- Signal-write guards and the legacy ExpressionChanged error enforce one law: rendering never writes state.
- NG-numbers are lookup keys at angular.dev/errors - translate first, debug second.
[
{
"q": "NG8001: 'app-star-rating' is not a known element - but the component definitely exists and compiles. What's missing?",
"choices": [
"The selector must start with a capital letter",
"StarRating isn't in the using component's imports array - standalone components declare their template dependencies explicitly",
"The component needs providedIn: 'root'",
"star-rating.ts must be renamed to match the selector"
],
"answer": 1,
"why": [
"Selectors are kebab-case by convention - casing isn't the issue.",
null,
"providedIn registers services with the injector; components enter templates through imports.",
"File names are free - the compiler resolves classes, not paths-by-convention."
],
"explain": "A standalone component's imports array is its complete template vocabulary. Existing and compiling isn't enough - the using component must import it."
},
{
"q": "A migrated-halfway codebase shows {{ count }} rendering as [object Object] in one component and {{ title() }} throwing in another. What single habit prevents both?",
"choices": [
"Always use parentheses in templates",
"Check each component's dialect first: signal declarations mean parens, decorator inputs mean none",
"Convert every component to signals before touching templates",
"Use the async pipe for all bindings"
],
"answer": 1,
"why": [
"Parens on a legacy field is exactly one of the two crashes.",
null,
"A full migration is the long-term fix, not the habit that keeps you safe on Tuesday.",
"The async pipe unwraps observables - neither of these is one."
],
"explain": "The two errors are mirror images of one mismatch. Ten seconds of checking whether the class says signal()/input() or @Input() tells you exactly how the template must read it."
},
{
"q": "In a legacy codebase, ExpressionChangedAfterItHasBeenCheckedError appears in development. What is Angular fundamentally objecting to?",
"choices": [
"The component tree is too deep",
"Some code changed bound state during change detection - rendering must not write state",
"zone.js is missing from the build",
"A signal was read without being called"
],
"answer": 1,
"why": [
"Depth is irrelevant - timing of a write is the whole story.",
null,
"This error comes FROM zone-era checking - its absence would preclude it.",
"That produces the [object Object] display, not this error."
],
"explain": "The dev-mode double check exists to catch writes that happen mid-render. Find the write the message names and move it to a handler, hook, or effect - same law signals now enforce upfront."
}
]
← Phase 6: HTTP and Just Enough RxJS · Guide overview · Phase 8: Where to Go Next →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. NG8001: 'app-star-rating' is not a known element - but the component definitely exists and compiles. What's missing?
2. A migrated-halfway codebase shows {{ count }} rendering as [object Object] in one component and {{ title() }} throwing in another. What single habit prevents both?
3. In a legacy codebase, ExpressionChangedAfterItHasBeenCheckedError appears in development. What is Angular fundamentally objecting to?