Comunicación bidireccional con model()

Avanzado
Angular
Angular
Actualizado: 25/09/2025

Two-way binding con model()

La función model() representa la evolución natural del two-way binding en Angular, combinando las capacidades de input() y output() en una sola definición elegante. Esta nueva API simplifica significativamente la comunicación bidireccional entre componentes padre e hijo.

Un model input es esencialmente un writable signal que permite tanto leer como escribir valores, estableciendo automáticamente el enlace bidireccional que tradicionalmente requeríamos configurar manualmente con inputs y outputs separados.

Definición básica con model()

Para crear un model input, utilizamos la función model() de forma similar a como definimos inputs normales:

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

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div>
      <p>Valor actual: {{ value() }}</p>
      <button (click)="increment()">Incrementar</button>
      <button (click)="decrement()">Decrementar</button>
    </div>
  `
})
export class CounterComponent {
  // Model input - writable signal
  value = model<number>(0);

  increment() {
    this.value.set(this.value() + 1);
  }

  decrement() {
    this.value.set(this.value() - 1);
  }
}

El model input value actúa como un signal normal que podemos leer llamándolo como función, pero además podemos modificarlo usando set() y update(), y estos cambios se propagan automáticamente al componente padre.

Sintaxis de binding bidireccional

En el template del componente padre, utilizamos la familiar sintaxis de banana-in-a-box [()] para establecer el two-way binding:

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [CounterComponent],
  template: `
    <div>
      <h2>Contador en el padre: {{ parentCounter() }}</h2>
      
      <!-- Two-way binding con model -->
      <app-counter [(value)]="parentCounter" />
      
      <p>El valor se sincroniza automáticamente</p>
      <button (click)="resetCounter()">Reset desde padre</button>
    </div>
  `
})
export class ParentComponent {
  parentCounter = signal(10);

  resetCounter() {
    this.parentCounter.set(0);
  }
}

La sintaxis [(value)]="parentCounter" establece automáticamente:

  • Binding de entrada: [value]="parentCounter" - El valor del padre se pasa al hijo
  • Binding de salida: (valueChange)="parentCounter.set($event)" - Los cambios del hijo actualizan el padre

Ventajas sobre el enfoque tradicional

Comparemos con el patrón tradicional usando @Input() y @Output():

Enfoque tradicional con decoradores:

// Componente hijo - enfoque legacy
export class CounterLegacyComponent {
  @Input() value = 0;
  @Output() valueChange = new EventEmitter<number>();

  increment() {
    this.value++;
    this.valueChange.emit(this.value);
  }
}

Enfoque moderno con model():

// Componente hijo - enfoque con signals
export class CounterComponent {
  value = model<number>(0);

  increment() {
    this.value.set(this.value() + 1);
    // El cambio se propaga automáticamente
  }
}

Las ventajas del model() son evidentes:

  • Menos código repetitivo: No necesitamos definir input y output por separado
  • Propagación automática: Los cambios se sincronizan sin emit manual
  • Type safety mejorado: TypeScript infiere automáticamente los tipos
  • Integración con signals: Funciona nativamente con el sistema reactivo moderno

Model inputs requeridos

Al igual que con input(), podemos definir model inputs obligatorios usando model.required():

@Component({
  selector: 'app-text-editor',
  template: `
    <textarea 
      [value]="content()" 
      (input)="updateContent($event)"
      placeholder="Escribe aquí...">
    </textarea>
  `
})
export class TextEditorComponent {
  // Model requerido - debe ser proporcionado por el padre
  content = model.required<string>();

  updateContent(event: Event) {
    const target = event.target as HTMLTextAreaElement;
    this.content.set(target.value);
  }
}

El uso en el padre sigue siendo el mismo:

@Component({
  template: `
    <div>
      <h3>Editor de texto</h3>
      <app-text-editor [(content)]="documentText" />
      
      <div class="preview">
        <h4>Vista previa:</h4>
        <p>{{ documentText() }}</p>
      </div>
    </div>
  `
})
export class DocumentComponent {
  documentText = signal('Contenido inicial');
}

Transformaciones en model inputs

Los model inputs también soportan transformaciones, similar a los inputs normales:

@Component({
  selector: 'app-price-input',
  template: `
    <div>
      <label>Precio:</label>
      <input 
        type="number" 
        [value]="price()" 
        (input)="updatePrice($event)"
        step="0.01">
      <span>€</span>
    </div>
  `
})
export class PriceInputComponent {
  // Model con transformación para asegurar precisión decimal
  price = model<number, string>(0, {
    transform: (value: string) => parseFloat(parseFloat(value).toFixed(2))
  });

  updatePrice(event: Event) {
    const target = event.target as HTMLInputElement;
    this.price.set(parseFloat(target.value));
  }
}

Esta aproximación moderna con model() no solo simplifica el código, sino que también proporciona una mejor experiencia de desarrollo al integrar perfectamente el two-way binding con el sistema de signals de Angular. La reactividad automática y la reducción del código boilerplate hacen que model() sea la opción preferida para la comunicación bidireccional en aplicaciones Angular modernas.

Signal binding bidireccional

Los model inputs establecen un sistema de reactividad bidireccional completamente basado en signals, creando un flujo de datos que se mantiene sincronizado automáticamente entre componentes padre e hijo. Esta sincronización va más allá del simple intercambio de valores, integrándose profundamente con todo el ecosistema reactivo de Angular.

Reactividad automática entre signals

Cuando un model input se conecta con un signal del componente padre, se establece una relación reactiva bidireccional. Cualquier cambio en cualquier extremo de esta conexión dispara automáticamente las actualizaciones correspondientes:

@Component({
  selector: 'app-reactive-slider',
  standalone: true,
  template: `
    <div class="slider-container">
      <input 
        type="range" 
        [value]="value()" 
        (input)="updateValue($event)"
        [min]="min" 
        [max]="max">
      
      <div class="value-display">
        <span>Valor: {{ value() }}</span>
        <span>Porcentaje: {{ percentage() }}%</span>
      </div>
    </div>
  `
})
export class ReactiveSliderComponent {
  value = model<number>(50);
  min = input<number>(0);
  max = input<number>(100);

  // Computed signal que reacciona a cambios del model
  percentage = computed(() => {
    const current = this.value();
    const range = this.max() - this.min();
    return Math.round((current / range) * 100);
  });

  updateValue(event: Event) {
    const target = event.target as HTMLInputElement;
    this.value.set(parseInt(target.value));
  }
}

En el componente padre, cualquier computed signal o effect que dependa del valor compartido se actualiza automáticamente:

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [ReactiveSliderComponent],
  template: `
    <div class="dashboard">
      <h2>Panel de Control</h2>
      
      <app-reactive-slider 
        [(value)]="volume" 
        [min]="0" 
        [max]="100" />
      
      <div class="status">
        <p>Volumen: {{ volumeStatus() }}</p>
        <p>Configuración: {{ config() }}</p>
      </div>
    </div>
  `
})
export class DashboardComponent {
  volume = signal(75);

  // Se actualiza automáticamente cuando el slider cambia
  volumeStatus = computed(() => {
    const vol = this.volume();
    if (vol === 0) return 'Silenciado';
    if (vol < 30) return 'Bajo';
    if (vol < 70) return 'Medio';
    return 'Alto';
  });

  config = computed(() => ({
    volume: this.volume(),
    timestamp: new Date().toISOString(),
    status: this.volumeStatus()
  }));
}

Effects reactivos con model inputs

Los effects pueden reaccionar tanto a cambios internos del componente como a modificaciones provenientes del componente padre a través del model binding:

@Component({
  selector: 'app-theme-selector',
  standalone: true,
  template: `
    <div class="theme-selector" [attr.data-theme]="selectedTheme()">
      <h3>Selector de Tema</h3>
      
      @for (theme of themes; track theme.id) {
        <button 
          class="theme-button"
          [class.active]="selectedTheme() === theme.id"
          (click)="selectTheme(theme.id)">
          {{ theme.name }}
        </button>
      }
    </div>
  `
})
export class ThemeSelectorComponent {
  selectedTheme = model<string>('light');
  
  themes = [
    { id: 'light', name: 'Claro' },
    { id: 'dark', name: 'Oscuro' },
    { id: 'auto', name: 'Automático' }
  ];

  constructor() {
    // Effect que reacciona a cambios del model
    effect(() => {
      const theme = this.selectedTheme();
      document.body.setAttribute('data-theme', theme);
      
      // Persistir en localStorage
      localStorage.setItem('user-theme', theme);
      
      console.log(`Tema actualizado a: ${theme}`);
    });
  }

  selectTheme(themeId: string) {
    this.selectedTheme.set(themeId);
  }
}

Composición de signals bidireccionales

Los model inputs pueden formar parte de cadenas reactivas más complejas, donde múltiples signals interactúan para crear comportamientos sofisticados:

@Component({
  selector: 'app-color-mixer',
  standalone: true,
  template: `
    <div class="color-mixer">
      <div class="color-preview" [style.background-color]="hexColor()">
        <span class="color-code">{{ hexColor() }}</span>
      </div>
      
      <div class="sliders">
        <div class="slider-group">
          <label>Rojo ({{ red() }})</label>
          <input type="range" [value]="red()" (input)="setRed($event)" min="0" max="255">
        </div>
        
        <div class="slider-group">
          <label>Verde ({{ green() }})</label>
          <input type="range" [value]="green()" (input)="setGreen($event)" min="0" max="255">
        </div>
        
        <div class="slider-group">
          <label>Azul ({{ blue() }})</label>
          <input type="range" [value]="blue()" (input)="setBlue($event)" min="0" max="255">
        </div>
      </div>
    </div>
  `
})
export class ColorMixerComponent {
  // Model inputs para cada componente RGB
  red = model<number>(255);
  green = model<number>(0);
  blue = model<number>(0);

  // Computed que combina los tres models
  rgbColor = computed(() => ({
    r: this.red(),
    g: this.green(),
    b: this.blue()
  }));

  // Computed derivado que genera el código hexadecimal
  hexColor = computed(() => {
    const rgb = this.rgbColor();
    const toHex = (n: number) => n.toString(16).padStart(2, '0');
    return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
  });

  setRed(event: Event) {
    const value = parseInt((event.target as HTMLInputElement).value);
    this.red.set(value);
  }

  setGreen(event: Event) {
    const value = parseInt((event.target as HTMLInputElement).value);
    this.green.set(value);
  }

  setBlue(event: Event) {
    const value = parseInt((event.target as HTMLInputElement).value);
    this.blue.set(value);
  }
}

En el componente padre, podemos trabajar con el color completo o sus componentes individuales:

@Component({
  selector: 'app-design-tool',
  standalone: true,
  imports: [ColorMixerComponent],
  template: `
    <div class="design-tool">
      <h2>Herramienta de Diseño</h2>
      
      <app-color-mixer 
        [(red)]="primaryRed" 
        [(green)]="primaryGreen" 
        [(blue)]="primaryBlue" />
      
      <div class="color-info">
        <p>Color principal: {{ primaryColorHex() }}</p>
        <p>Brillo: {{ brightness() }}%</p>
        <p>Es color claro: {{ isLightColor() ? 'Sí' : 'No' }}</p>
      </div>
      
      <button (click)="randomizeColor()">Color Aleatorio</button>
    </div>
  `
})
export class DesignToolComponent {
  primaryRed = signal(128);
  primaryGreen = signal(128);
  primaryBlue = signal(128);

  // Computed signals que reaccionan a los cambios bidireccionales
  primaryColorHex = computed(() => {
    const toHex = (n: number) => n.toString(16).padStart(2, '0');
    return `#${toHex(this.primaryRed())}${toHex(this.primaryGreen())}${toHex(this.primaryBlue())}`;
  });

  brightness = computed(() => {
    const r = this.primaryRed();
    const g = this.primaryGreen();
    const b = this.primaryBlue();
    return Math.round((r * 299 + g * 587 + b * 114) / 1000 / 255 * 100);
  });

  isLightColor = computed(() => this.brightness() > 50);

  randomizeColor() {
    this.primaryRed.set(Math.floor(Math.random() * 256));
    this.primaryGreen.set(Math.floor(Math.random() * 256));
    this.primaryBlue.set(Math.floor(Math.random() * 256));
  }
}

Sincronización de estado complejo

Los model inputs también pueden manejar objetos complejos, manteniendo la reactividad en estructuras de datos más elaboradas:

interface UserPreferences {
  theme: 'light' | 'dark' | 'auto';
  fontSize: number;
  notifications: boolean;
  language: string;
}

@Component({
  selector: 'app-preferences-panel',
  standalone: true,
  template: `
    <div class="preferences-panel">
      <h3>Preferencias de Usuario</h3>
      
      <div class="preference-group">
        <label>Tamaño de fuente: {{ preferences().fontSize }}px</label>
        <input 
          type="range" 
          [value]="preferences().fontSize"
          (input)="updateFontSize($event)"
          min="12" max="24">
      </div>
      
      <div class="preference-group">
        <label>
          <input 
            type="checkbox" 
            [checked]="preferences().notifications"
            (change)="toggleNotifications()">
          Activar notificaciones
        </label>
      </div>
      
      <div class="preference-group">
        <select 
          [value]="preferences().language"
          (change)="updateLanguage($event)">
          <option value="es">Español</option>
          <option value="en">English</option>
          <option value="fr">Français</option>
        </select>
      </div>
    </div>
  `
})
export class PreferencesPanelComponent {
  preferences = model.required<UserPreferences>();

  updateFontSize(event: Event) {
    const fontSize = parseInt((event.target as HTMLInputElement).value);
    this.preferences.update(prefs => ({
      ...prefs,
      fontSize
    }));
  }

  toggleNotifications() {
    this.preferences.update(prefs => ({
      ...prefs,
      notifications: !prefs.notifications
    }));
  }

  updateLanguage(event: Event) {
    const language = (event.target as HTMLSelectElement).value;
    this.preferences.update(prefs => ({
      ...prefs,
      language
    }));
  }
}

Este patrón de signal binding bidireccional crea un sistema reactivo robusto donde los cambios fluyen naturalmente en ambas direcciones, manteniendo la sincronización automática y proporcionando una base sólida para construir interfaces de usuario complejas y responsivas.

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 el concepto de two-way binding con la función model() en Angular.
  • Aprender a definir model inputs como writable signals para comunicación bidireccional.
  • Conocer la sintaxis de binding bidireccional usando la notación banana-in-a-box.
  • Identificar las ventajas del enfoque model() frente al patrón tradicional con @Input y @Output.
  • Aplicar model inputs en escenarios con transformaciones, efectos reactivos y sincronización de estados complejos.