Signal Queries: viewChild y contentChild

Avanzado
Angular
Angular
Actualizado: 25/09/2025

viewChild() y contentChild()

Las signal queries representan una evolución natural de las queries tradicionales de Angular, ofreciendo una alternativa moderna y reactiva a los decoradores @ViewChild y @ContentChild. Estas nuevas funciones devuelven signals que se actualizan automáticamente cuando los elementos referenciados cambian, eliminando la complejidad de los lifecycle hooks tradicionales.

Sintaxis básica de viewChild()

La función viewChild() permite obtener referencias a elementos del DOM o componentes hijos dentro del template del componente actual. Su sintaxis es considerablemente más simple que su contraparte decoradora:

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

@Component({
  selector: 'app-ejemplo',
  standalone: true,
  template: `
    <input #nombreInput type="text" placeholder="Introduce tu nombre">
    <button (click)="focusInput()">Enfocar input</button>
  `
})
export class EjemploComponent {
  // Signal query para el input
  nombreInput = viewChild<ElementRef<HTMLInputElement>>('nombreInput');

  focusInput() {
    const input = this.nombreInput();
    if (input) {
      input.nativeElement.focus();
    }
  }
}

La ventaja inmediata es que no necesitamos ngAfterViewInit para acceder al elemento. El signal se actualiza automáticamente cuando el elemento está disponible.

Queries obligatorias y opcionales

Por defecto, viewChild() devuelve un signal que puede contener undefined si el elemento no se encuentra. Para elementos que sabemos que siempre existirán, podemos usar viewChild.required():

@Component({
  selector: 'app-contador',
  standalone: true,
  template: `
    <div #contadorDisplay class="contador">{{ contador() }}</div>
    <button (click)="incrementar()">+1</button>
  `
})
export class ContadorComponent {
  contador = signal(0);
  
  // Signal query obligatoria - nunca será undefined
  display = viewChild.required<ElementRef>('contadorDisplay');

  incrementar() {
    this.contador.update(val => val + 1);
    // Podemos usar directamente sin verificar null
    this.display().nativeElement.classList.add('actualizado');
  }
}

contentChild() para contenido proyectado

La función contentChild() funciona de manera similar pero busca dentro del contenido proyectado con <ng-content>. Es especialmente útil para componentes que reciben contenido desde el padre:

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <div class="card-header">
        <ng-content select="[slot=header]"></ng-content>
      </div>
      <div class="card-body">
        <ng-content></ng-content>
      </div>
    </div>
  `
})
export class CardComponent {
  // Busca en el contenido proyectado
  headerContent = contentChild<ElementRef>('[slot=header]');

  ngAfterContentInit() {
    const header = this.headerContent();
    if (header) {
      console.log('Header proyectado encontrado:', header.nativeElement.textContent);
    }
  }
}

Uso del componente:

@Component({
  template: `
    <app-card>
      <h2 slot="header">Mi título</h2>
      <p>Contenido de la tarjeta</p>
    </app-card>
  `
})
export class ParentComponent {}

Búsqueda por tipo de componente

Además de buscar por template reference variables, podemos buscar directamente por tipo de componente:

@Component({
  selector: 'app-usuario',
  standalone: true,
  template: `
    <h3>{{ nombre }}</h3>
    <button (click)="editar()">Editar</button>
  `
})
export class UsuarioComponent {
  @Input() nombre = '';
  
  editar() {
    console.log('Editando usuario:', this.nombre);
  }
}

@Component({
  selector: 'app-lista',
  standalone: true,
  imports: [UsuarioComponent],
  template: `
    <app-usuario nombre="Ana"></app-usuario>
    <app-usuario nombre="Carlos"></app-usuario>
    <button (click)="editarPrimero()">Editar primero</button>
  `
})
export class ListaComponent {
  // Busca el primer componente de tipo UsuarioComponent
  primerUsuario = viewChild(UsuarioComponent);

  editarPrimero() {
    const usuario = this.primerUsuario();
    if (usuario) {
      usuario.editar();
    }
  }
}

Reactividad automática con effects

Una de las ventajas más significativas de las signal queries es su integración natural con effects. Podemos reaccionar automáticamente cuando las referencias cambian:

@Component({
  selector: 'app-modal',
  standalone: true,
  template: `
    @if (mostrar()) {
      <div #modalElement class="modal">
        <h2>Modal dinámico</h2>
        <button (click)="cerrar()">Cerrar</button>
      </div>
    }
    <button (click)="abrir()">Abrir modal</button>
  `
})
export class ModalComponent {
  mostrar = signal(false);
  modalRef = viewChild<ElementRef>('modalElement');

  constructor() {
    // Effect que se ejecuta cuando modalRef cambia
    effect(() => {
      const modal = this.modalRef();
      if (modal) {
        // Enfocar automáticamente cuando el modal aparece
        modal.nativeElement.focus();
        console.log('Modal montado en el DOM');
      }
    });
  }

  abrir() {
    this.mostrar.set(true);
  }

  cerrar() {
    this.mostrar.set(false);
  }
}

Diferencias con @ViewChild y @ContentChild

Las signal queries ofrecen varias mejoras respecto a los decoradores tradicionales:

  • 1. Reactividad automática: No necesitamos lifecycle hooks para detectar cambios en las referencias
  • 2. Integración con signals: Se combinan naturalmente con computed signals y effects
  • 3. Sintaxis más limpia: Menos boilerplate y configuración
  • 4. Type safety mejorada: TypeScript puede inferir mejor los tipos
  • 5. Mejor debugging: Los signals son más fáciles de debuggear que las propiedades decoradas

Comparación práctica:

// Forma tradicional con decoradores
export class ComponenteTradicional {
  @ViewChild('elemento') elemento!: ElementRef;

  ngAfterViewInit() {
    // Solo disponible después de este lifecycle hook
    this.elemento.nativeElement.focus();
  }
}

// Forma moderna con signal queries
export class ComponenteModerno {
  elemento = viewChild<ElementRef>('elemento');

  constructor() {
    // Disponible reactivamente
    effect(() => {
      const el = this.elemento();
      if (el) {
        el.nativeElement.focus();
      }
    });
  }
}

Las signal queries representan la evolución natural hacia un Angular más reactivo y declarativo, donde las referencias a elementos se comportan como cualquier otro estado reactivo de la aplicación.

Queries reactivas con signals

Las signal queries transforman las referencias a elementos en primitivos reactivos que se integran completamente con el ecosistema de signals de Angular. Esta integración permite crear patrones reactivos sofisticados que responden automáticamente a cambios en el DOM y el estado de los componentes.

Computed signals derivados de queries

Los computed signals pueden derivar valores directamente de signal queries, creando cálculos que se actualizan automáticamente cuando las referencias cambian:

@Component({
  selector: 'app-formulario',
  standalone: true,
  template: `
    <form #formulario>
      <input name="email" type="email" required>
      <input name="password" type="password" required>
      <button [disabled]="!esFormularioValido()">Enviar</button>
    </form>
    
    <div class="estado">
      Estado: {{ estadoFormulario() }}
    </div>
  `
})
export class FormularioComponent {
  formularioRef = viewChild<ElementRef<HTMLFormElement>>('formulario');
  
  // Computed signal que deriva del estado del formulario
  esFormularioValido = computed(() => {
    const form = this.formularioRef();
    return form ? form.nativeElement.checkValidity() : false;
  });
  
  // Computed signal para mostrar el estado
  estadoFormulario = computed(() => {
    const form = this.formularioRef();
    if (!form) return 'Cargando...';
    
    const elemento = form.nativeElement;
    if (elemento.checkValidity()) {
      return 'Válido ✓';
    }
    return `Inválido: ${elemento.validationMessage}`;
  });
}

Reactividad en cadena con múltiples queries

Las signal queries permiten crear cadenas reactivas complejas donde el cambio de una query puede afectar a otras:

@Component({
  selector: 'app-editor',
  standalone: true,
  template: `
    <div class="editor">
      <textarea #contenido (input)="actualizarContenido()">
        {{ textoInicial }}
      </textarea>
      
      <div class="estadisticas" #stats>
        Palabras: {{ estadisticas().palabras }}
        Caracteres: {{ estadisticas().caracteres }}
      </div>
      
      <div #preview class="preview">
        {{ contenidoProcesado() }}
      </div>
    </div>
  `
})
export class EditorComponent {
  textoInicial = 'Escribe tu contenido aquí...';
  
  contenidoRef = viewChild<ElementRef<HTMLTextAreaElement>>('contenido');
  statsRef = viewChild<ElementRef>('stats');
  previewRef = viewChild<ElementRef>('preview');
  
  // Signal para el contenido actual
  contenidoActual = signal('');
  
  // Computed signal para estadísticas
  estadisticas = computed(() => {
    const texto = this.contenidoActual();
    return {
      palabras: texto.trim() ? texto.trim().split(/\s+/).length : 0,
      caracteres: texto.length
    };
  });
  
  // Computed signal para contenido procesado
  contenidoProcesado = computed(() => {
    const texto = this.contenidoActual();
    return texto.replace(/\n/g, '<br>');
  });
  
  constructor() {
    // Effect que sincroniza cambios visuales
    effect(() => {
      const stats = this.estadisticas();
      const statsElement = this.statsRef();
      
      if (statsElement) {
        // Animación cuando cambian las estadísticas
        statsElement.nativeElement.classList.add('actualizado');
        setTimeout(() => {
          statsElement.nativeElement.classList.remove('actualizado');
        }, 300);
      }
    });
    
    // Effect para actualizar preview
    effect(() => {
      const contenido = this.contenidoProcesado();
      const preview = this.previewRef();
      
      if (preview) {
        preview.nativeElement.innerHTML = contenido;
      }
    });
  }
  
  actualizarContenido() {
    const textarea = this.contenidoRef();
    if (textarea) {
      this.contenidoActual.set(textarea.nativeElement.value);
    }
  }
}

Observación reactiva de cambios en el DOM

Las signal queries pueden combinarse con effects para observar cambios específicos en elementos del DOM:

@Component({
  selector: 'app-lista-dinamica',
  standalone: true,
  template: `
    <div class="controles">
      <button (click)="agregarItem()">Agregar</button>
      <button (click)="limpiar()">Limpiar</button>
    </div>
    
    <ul #lista class="items">
      @for (item of items(); track item.id) {
        <li class="item">{{ item.nombre }}</li>
      }
    </ul>
    
    <div class="info">
      Altura total: {{ alturaLista() }}px
      Elementos visibles: {{ elementosVisibles() }}
    </div>
  `
})
export class ListaDinamicaComponent {
  items = signal([
    { id: 1, nombre: 'Elemento 1' },
    { id: 2, nombre: 'Elemento 2' }
  ]);
  
  listaRef = viewChild<ElementRef<HTMLUListElement>>('lista');
  
  // Computed signal para altura de la lista
  alturaLista = computed(() => {
    const lista = this.listaRef();
    return lista ? lista.nativeElement.scrollHeight : 0;
  });
  
  // Computed signal para elementos visibles
  elementosVisibles = computed(() => {
    const lista = this.listaRef();
    if (!lista) return 0;
    
    const elemento = lista.nativeElement;
    const alturaVisible = elemento.clientHeight;
    const alturaItem = 40; // altura aproximada de cada item
    
    return Math.floor(alturaVisible / alturaItem);
  });
  
  constructor() {
    // Effect que observa cambios en el tamaño de la lista
    effect(() => {
      const altura = this.alturaLista();
      const lista = this.listaRef();
      
      if (lista && altura > 300) {
        // Activar scroll cuando la lista es muy alta
        lista.nativeElement.style.maxHeight = '300px';
        lista.nativeElement.style.overflowY = 'auto';
      }
    });
    
    // Effect para logging de cambios
    effect(() => {
      const cantidad = this.items().length;
      const altura = this.alturaLista();
      
      console.log(`Lista actualizada: ${cantidad} items, ${altura}px`);
    });
  }
  
  agregarItem() {
    const id = Date.now();
    this.items.update(items => [
      ...items,
      { id, nombre: `Elemento ${id}` }
    ]);
  }
  
  limpiar() {
    this.items.set([]);
  }
}

Integración con resource() para datos asíncronos

Las signal queries se combinan eficazmente con la resource API para crear interfaces reactivas que responden tanto a cambios de datos como de DOM:

@Component({
  selector: 'app-datos-visuales',
  standalone: true,
  template: `
    <div class="controles">
      <input #filtro (input)="aplicarFiltro()" placeholder="Filtrar datos...">
    </div>
    
    <div #contenedor class="datos">
      @if (datosRecurso.loading()) {
        <div class="loading">Cargando datos...</div>
      }
      
      @if (datosRecurso.value(); as datos) {
        @for (item of datosFiltrados(); track item.id) {
          <div class="item-dato">{{ item.nombre }}</div>
        }
      }
    </div>
    
    <div class="estadisticas">
      Total: {{ datosFiltrados().length }} elementos
      Altura contenedor: {{ alturaContenedor() }}px
    </div>
  `
})
export class DatosVisualesComponent {
  filtroTexto = signal('');
  
  // Resource para datos asíncronos
  datosRecurso = resource({
    request: () => ({}),
    loader: async () => {
      // Simular carga de datos
      await new Promise(resolve => setTimeout(resolve, 1000));
      return [
        { id: 1, nombre: 'Dato A' },
        { id: 2, nombre: 'Dato B' },
        { id: 3, nombre: 'Dato C' }
      ];
    }
  });
  
  // Signal queries para elementos del DOM
  filtroRef = viewChild<ElementRef<HTMLInputElement>>('filtro');
  contenedorRef = viewChild<ElementRef>('contenedor');
  
  // Computed signal para datos filtrados
  datosFiltrados = computed(() => {
    const datos = this.datosRecurso.value() ?? [];
    const filtro = this.filtroTexto().toLowerCase();
    
    if (!filtro) return datos;
    
    return datos.filter(item => 
      item.nombre.toLowerCase().includes(filtro)
    );
  });
  
  // Computed signal para altura del contenedor
  alturaContenedor = computed(() => {
    const contenedor = this.contenedorRef();
    return contenedor ? contenedor.nativeElement.offsetHeight : 0;
  });
  
  constructor() {
    // Effect que ajusta el scroll cuando cambian los datos filtrados
    effect(() => {
      const datos = this.datosFiltrados();
      const contenedor = this.contenedorRef();
      
      if (contenedor && datos.length > 0) {
        // Scroll al inicio cuando cambia el filtro
        contenedor.nativeElement.scrollTop = 0;
      }
    });
  }
  
  aplicarFiltro() {
    const input = this.filtroRef();
    if (input) {
      this.filtroTexto.set(input.nativeElement.value);
    }
  }
}

Composición de queries con linkedSignal

Los linkedSignal pueden utilizarse junto con queries para crear relaciones bidireccionales entre el estado y el DOM:

@Component({
  selector: 'app-configurador',
  standalone: true,
  template: `
    <div class="configuracion">
      <input #tamanoSlider 
             type="range" 
             min="12" 
             max="24" 
             [value]="tamaноTexto()"
             (input)="actualizarTamano()">
      
      <div #textoMuestra 
           class="muestra"
           [style.font-size.px]="tamaноTexto()">
        Texto de ejemplo con tamaño {{ tamaноTexto() }}px
      </div>
    </div>
  `
})
export class ConfiguradorComponent {
  // Signal base para el tamaño
  tamaноBase = signal(16);
  
  // LinkedSignal que sincroniza con el DOM
  tamaноTexto = linkedSignal({
    source: this.tamaноBase,
    computation: (base) => base,
  });
  
  sliderRef = viewChild<ElementRef<HTMLInputElement>>('tamanoSlider');
  textoRef = viewChild<ElementRef>('textoMuestra');
  
  constructor() {
    // Effect que mantiene sincronizado el slider
    effect(() => {
      const tamano = this.tamaноTexto();
      const slider = this.sliderRef();
      
      if (slider) {
        slider.nativeElement.value = tamano.toString();
      }
    });
    
    // Effect que aplica estilos adicionales basados en el tamaño
    effect(() => {
      const tamano = this.tamaноTexto();
      const texto = this.textoRef();
      
      if (texto) {
        const elemento = texto.nativeElement;
        
        // Aplicar clase según el tamaño
        elemento.classList.toggle('texto-pequeno', tamano < 16);
        elemento.classList.toggle('texto-grande', tamano > 20);
      }
    });
  }
  
  actualizarTamano() {
    const slider = this.sliderRef();
    if (slider) {
      const nuevoTamano = parseInt(slider.nativeElement.value);
      this.tamaноTexto.set(nuevoTamano);
    }
  }
}

La reactividad con signal queries convierte las referencias del DOM en ciudadanos de primera clase del sistema reactivo de Angular, permitiendo crear interfaces que responden fluidamente tanto a cambios de estado como a modificaciones en la estructura del DOM.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Angular

Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Comprender la sintaxis y funcionamiento de las signal queries viewChild y contentChild.
  • Diferenciar entre queries obligatorias y opcionales y su manejo.
  • Integrar signal queries con efectos reactivos y computed signals para crear interfaces dinámicas.
  • Aplicar signal queries para acceder a contenido proyectado y componentes por tipo.
  • Explorar la reactividad avanzada y composición con linkedSignal y resource para datos asíncronos.