Skip to content

🔍 Clase 6 — ViewChild y ContentChild

📅 2026-07-18 · 🗂️ Proyecto: AppClase04 (destino en este repo: app-semana04) · 🎯 @ViewChild() y @ContentChild()

⬅️ Viene después de Clase 4 y Clase 5 (mismo día de clase).

🗂️ Temas de esta clase

  1. viewChild(): acceder desde la clase TS a un elemento del propio template del componente, usando la API de signals (no el decorador clásico @ViewChild()).
  2. @ContentChild(): acceder a un elemento o componente que llega por <ng-content> (contenido proyectado desde el padre) — pendiente de ver.

🗺️ Diagrama: la diferencia entre los dos

Diagrama comparando viewChild() y contentChild() en Angular: viewChild lee un elemento declarado en el propio template del componente (ejemplo Formulario con #campoNombre); contentChild lee un elemento que el padre proyecta dentro del hijo vía ng-content (ejemplo App pasando un h2 #titulo dentro de app-caja)

  • viewChild() lee algo que el propio componente ya tiene en su HTML — el <input #campoNombre> vive dentro de formulario.html, junto con la clase que lo lee.
  • contentChild() lee algo que no está en el HTML del componente, sino que el padre lo escribe entre las etiquetas del hijo (<app-caja>...</app-caja>) y Angular lo proyecta adentro vía <ng-content>.

🧩 viewChild(): referenciar un <input> del propio template

Componente formulario, con un botón que enfoca el <input> por código:

ts
import { Component, viewChild } from '@angular/core';

@Component({
  selector: 'app-formulario',
  imports: [],
  templateUrl: './formulario.html',
  styleUrl: './formulario.css',
})
export class Formulario {

  campoNombre = viewChild<HTMLInputElement>('campoNombre');

  enfocar() {
    this.campoNombre()?.focus();
  }

}
html
<input #campoNombre type="text" placeholder="Tu nombre" />
<button (click)="enfocar()">Enfocar Input</button>
  • #campoNombre (en el HTML) es una template reference variable: un "gancho" con nombre sobre ese <input> concreto, visible solo dentro de ese mismo template.
  • viewChild<HTMLInputElement>('campoNombre') — la función (nueva API de signals, Angular 17.3+) busca en el template el elemento marcado con #campoNombre y devuelve un signal con la referencia. El genérico <HTMLInputElement> le dice a TypeScript qué tipo de elemento es, para poder usar .focus() sin error de tipos.
  • campoNombre() — como es un signal, para leer el valor se llama como función (con paréntesis). ?.focus() porque al principio (antes de que Angular pinte la vista) el signal puede valer undefined.
  • (click)="enfocar()" — Event Binding de repaso: al hacer clic, se llama al método que usa la referencia para enfocar el <input> por código (sin que el usuario haga clic directo en la cajita de texto).

📌 viewChild() (función/signal) vs @ViewChild() (decorador clásico): hacen lo mismo (referenciar algo del propio template), pero viewChild() es la forma moderna (Angular 17.3+), devuelve un signal de solo lectura y no necesita el hook ngAfterViewInit para leer el valor con seguridad (antes, con el decorador clásico, había que esperar a ngAfterViewInit porque la referencia llegaba undefined hasta ese momento). → ver ciclo-de-vida.md para ese hook.

🧪 Tip de entrevista: ¿Por qué campoNombre()?.focus() y no campoNombre.focus() directo? → campoNombre es un signal (una función que envuelve el valor), no el elemento en sí. Hay que llamarlo (campoNombre()) para obtener el HTMLInputElement real; el ?. es porque puede no existir todavía (antes de que la vista se termine de renderizar).

⚠️ Error real que salió armando esto: el profe escribió this.campoNombre()?.nativeElement.focus(), mezclando la sintaxis del decorador clásico @ViewChild (que envuelve el resultado en ElementRef, y por eso hace falta .nativeElement para llegar al elemento real) con la función nuevaviewChild<HTMLInputElement>(...) (que, al pedirle el tipo HTMLInputElement directamente, ya devuelve el elemento, sin envoltorio). El compilador lo marca con TS2339: Property 'nativeElement' does not exist on type 'HTMLInputElement'. La versión correcta es sin .nativeElement: this.campoNombre()?.focus().

📦 contentChild(): leer texto de contenido proyectado (<ng-content>)

Componente caja — a diferencia de formulario (que referencia algo de su propio template), este lee un elemento que le llega de afuera, proyectado por el padre:

ts
import { Component, contentChild, ElementRef, AfterContentInit } from '@angular/core';

@Component({
  selector: 'app-caja',
  imports: [],
  templateUrl: './caja.html',
  styleUrl: './caja.css',
})
export class Caja {

  titulo = contentChild<ElementRef>('titulo');

  ngAfterContentInit(): void {
    console.log('Texto recibido: ', this.titulo()?.nativeElement.textContent);
  }

}
  • contentChild<ElementRef>('titulo') — la versión de contentChild para contenido proyectado (lo que el padre pone dentro de <app-caja>...</app-caja>, vía <ng-content> en el template de Caja). Busca el elemento marcado #tituloentre el contenido proyectado, no en el propio template de Caja.
  • <ElementRef> en vez de <HTMLInputElement> — aquí el genérico sí es ElementRef a propósito, por eso .nativeElement.textContent es correcto (al revés del error que salió en viewChild<HTMLInputElement>() de formulario, donde .nativeElementsobraba). Comparar los dos ayuda a fijar la regla: el tipo que le pides al genérico es exactamente lo que titulo()/campoNombre() te va a devolver.
  • ngAfterContentInit() — hook del ciclo de vida que se dispara después de que Angular termina de proyectar el contenido (<ng-content>) por primera vez. Por eso se lee contentChild ahí, y no en ngOnInit (que corre antes, cuando el contenido proyectado todavía no está listo). → ver ciclo-de-vida.md.

📄 caja.html y cómo se usa desde app.html

html
<!-- caja.html -->
<div style="border:2px solid green;">

    <ng-content></ng-content>

</div>
html
<!-- app.html -->
<app-caja>
    <h2 #titulo> Este texto viene de afuera </h2>
</app-caja>
  • <ng-content></ng-content> — es el "hueco" donde Angular inserta lo que el padre ponga entre <app-caja> y </app-caja>. El <div> con borde verde es solo un marco visual para ver dónde termina el componente Caja y dónde empieza el contenido proyectado.
  • <h2 #titulo> — esto lo escribe el padre (App), no Caja. Es el contenido que se proyecta; como tiene la referencia #titulo, contentChild<ElementRef>('titulo') en Caja lo encuentra y ngAfterContentInit imprime su texto: "Texto recibido: Este texto viene de afuera".
  • Diferencia clave con viewChild (formulario): ahí #campoNombre estaba dentro del propio template de Formulario; aquí #titulo está fuera, en el template del padre, y solo "aparece" dentro de Caja gracias a <ng-content>.

▶️ Cómo correrlo

zsh · app-semana04
$ cd 02-Ejercicios/app-semana04
$ npm install     // solo la primera vez
$ npm start       // = ng serve → http://localhost:4200/

En http://localhost:4200/ ya están cableados en app.html: <app-formulario> (el <input> con el botón "Enfocar Input" que usa viewChild()) y <app-caja> (con el <h2 #titulo> proyectado adentro, dentro del recuadro verde, que contentChild() lee por consola en ngAfterContentInit).

🏋️ Ejercicios con solución

Ejercicio 1 — viewChild() sobre un segundo elemento del propio template

En formulario.html agrega un segundo <input> (por ejemplo, uno de email) con su propia referencia, y un botón que lo enfoque a él en vez del primero. Usa la misma función viewChild() que ya viste con campoNombre.

Ver solución
html
<!-- formulario.html -->
<input #campoNombre type="text" placeholder="Tu nombre" />
<button (click)="enfocar()">Enfocar Input</button>

<input #campoEmail type="email" placeholder="Tu email" />
<button (click)="enfocarEmail()">Enfocar Email</button>
ts
// formulario.ts
import { Component, viewChild } from '@angular/core';

@Component({
  selector: 'app-formulario',
  imports: [],
  templateUrl: './formulario.html',
  styleUrl: './formulario.css',
})
export class Formulario {

  campoNombre = viewChild<HTMLInputElement>('campoNombre');
  campoEmail = viewChild<HTMLInputElement>('campoEmail');

  enfocar(): void {
    this.campoNombre()?.focus();
  }

  enfocarEmail(): void {
    this.campoEmail()?.focus();
  }

}

Misma regla que con campoNombre: #campoEmail es la template reference variable en el HTML, viewChild<HTMLInputElement>('campoEmail') la busca en el propio template de Formulario, y campoEmail()?.focus() se llama como signal (con paréntesis) porque puede valer undefined antes de que la vista termine de renderizarse.

Ejercicio 2 — contentChild() con otro elemento proyectado

En vez de proyectar un <h2 #titulo> dentro de <app-caja>, prueba proyectar un <p #descripcion> y léelo desde Caja con contentChild(), imprimiendo su texto en ngAfterContentInit.

Ver solución
html
<!-- app.html -->
<app-caja>
    <p #descripcion> Esta descripción también viene de afuera </p>
</app-caja>
ts
// caja.ts
import { Component, contentChild, ElementRef, AfterContentInit } from '@angular/core';

@Component({
  selector: 'app-caja',
  imports: [],
  templateUrl: './caja.html',
  styleUrl: './caja.css',
})
export class Caja implements AfterContentInit {

  descripcion = contentChild<ElementRef>('descripcion');

  ngAfterContentInit(): void {
    console.log('Descripción recibida: ', this.descripcion()?.nativeElement.textContent);
  }

}

caja.html no cambia (sigue con el mismo <ng-content></ng-content> genérico): no le importa qué proyecta el padre, solo que quien lo proyecte tenga la referencia (#descripcion) que contentChild() está buscando. Como en el ejemplo original con #titulo, el genérico es <ElementRef> (no <HTMLParagraphElement>), así que .nativeElement.textContent es correcto.

❓ Preguntas y respuestas

1. ¿Cuál es la diferencia clave entre viewChild() y contentChild()?

viewChild() lee un elemento que está dentro del propio template del componente (ejemplo: #campoNombre en formulario.html, junto con la clase que lo lee). contentChild() lee un elemento que el padre proyecta desde afuera, vía <ng-content> (ejemplo: #titulo que App escribe entre <app-caja>...</app-caja>).

2. ¿Por qué contentChild se lee en ngAfterContentInit y no en ngOnInit?

Porque ngOnInit se ejecuta antes de que Angular termine de proyectar el contenido del padre — en ese momento titulo() todavía valdría undefined. ngAfterContentInit se dispara después de la primera proyección de <ng-content>, que es cuando la referencia ya está disponible con seguridad. → ver ciclo-de-vida.md.

3. En campoNombre = viewChild<HTMLInputElement>('campoNombre') vs titulo = contentChild<ElementRef>('titulo'), ¿por qué el genérico es distinto?

El genérico define exactamente lo que la función va a devolver al llamarla. Pedir <HTMLInputElement> hace que campoNombre() entregue el elemento ya "pelado", por eso .focus() va directo, sin .nativeElement. Pedir <ElementRef> hace que titulo() entregue el envoltorio ElementRef, por eso hace falta .nativeElement.textContent para llegar al elemento real. Mezclar ambos (pedir HTMLInputElement y después escribir .nativeElement) es justo el error que salió documentado más arriba.

4. ¿viewChild() y contentChild() devuelven el valor directo o hay que llamarlos?

Son signals: hay que llamarlos como función (campoNombre(), titulo()) para leer el valor actual. Por eso siempre aparecen con ?. antes del resto (?.focus(), ?.nativeElement) — al principio, antes de que Angular termine de pintar la vista (o de proyectar el contenido), el signal puede valer undefined.

❓ Pendiente

  • [x] Código real de contentChild() (ya confirmado, con ngAfterContentInit).
  • [x] caja.html real (con <ng-content>) y cómo lo usa app.html (el contenido proyectado con #titulo).
  • [x] Diferencia práctica entre viewChild y contentChild: viewChild lee tu propio template, contentChild lee lo que el padre te proyecta vía <ng-content>.
  • [ ] Ver si el profe también muestra el decorador clásico @ViewChild()/@ContentChild() para comparar con las funciones nuevas.
  • [ ] Confirmar si Caja termina con implements AfterContentInit (el import ya está).

📎 Apuntes relacionados

  • ciclo-de-vida.md — los hooks (ngAfterViewInit, ngAfterContentInit) suelen aparecer junto con viewChild/contentChild, porque la referencia recién está disponible después de esos momentos del ciclo de vida.
  • 02-Conceptos.md — se resumirá aquí también en cuanto haya código confirmado.

➡️ Siguiente

Ver si aparece el decorador clásico @ViewChild()/@ContentChild() para comparar con las funciones de signals ya vistas, y confirmar implements AfterContentInit en Caja.