DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

I stopped guessing when viewChild is ready

For ten years, @ViewChild came with a pop quiz. Is the element always there, or behind an *ngIf? Then is it static: true or static: false? And given that, do you read it in ngOnInit or ngAfterViewInit? Answer wrong and you get undefined — not always, just on the runs where timing didn't go your way. I've watched senior developers tab over to the docs for this. I've been the senior developer tabbing over to the docs.

The signal version deletes the quiz:

chartCanvas = viewChild<ElementRef>('canvas');
Enter fullscreen mode Exit fullscreen mode

That's a Signal<ElementRef | undefined>. Read it whenever you like — there is no wrong lifecycle moment anymore. Before the view exists it's undefined, afterwards it's the element, and anything that depends on it reacts when it changes. The question "when is it ready?" stopped needing an answer, because the reactive graph waits with you.

Conditional elements become a non-event

The old timing anxiety was worst with elements inside conditionals. Now the element's appearance is just a signal transition:

details = viewChild<ElementRef>('details');

constructor() {
  effect(() => {
    const el = this.details();
    if (el) el.nativeElement.scrollIntoView();
  });
}
Enter fullscreen mode Exit fullscreen mode

The @if flips, the element mounts, the query signal goes from undefined to the ref, the effect fires. Element leaves, signal returns to undefined. This also covers a case the old API handled badly: content that arrives late via @defer. No hook fires "when the defer block loaded" — but the query signal updates, and your effect runs. Free integration between two features that never explicitly met.

required: say what you mean

canvas = viewChild.required<ElementRef>('canvas');
Enter fullscreen mode Exit fullscreen mode

For elements that are unconditionally in the template, required strips undefined from the type — no optional chaining, no non-null assertions sprinkled around. If the element genuinely can't be found, you get a clear runtime error instead of a silent undefined propagating into a cryptic one three calls later. Like input.required, it moves a convention ("this should always exist") into the type system, where conventions go to become guarantees.

Lists of elements, same deal

rows = viewChildren(RowComponent);
rowCount = computed(() => this.rows().length);
anyExpanded = computed(() => this.rows().some(r => r.expanded()));
Enter fullscreen mode Exit fullscreen mode

viewChildren() returns a signal of a plain array — and with it goes QueryList, its .changes observable, and the subscription you had to manage to know when rows appeared. Deriving "is any row expanded" is a one-line computed over an array, which is how it always should have felt.

The honest caveat

One pattern doesn't translate: one-shot imperative setup that must happen exactly once after first render — initializing a chart library, measuring layout. An effect can do it with a guard flag, but that's the effect-as-lifecycle-hook smell. The dedicated tool is afterNextRender(), which also happens to be the SSR-safe choice (it simply never runs on the server — that one's worth its own note). My rule of thumb: reacting to elements coming and going → query signal + effect; doing something once when the view first exists → afterNextRender. Between the two, I haven't written ngAfterViewInit in months, and I haven't missed the pop quiz once.

Top comments (0)