Ir al contenido

Reactividad con signals

Este módulo es de lectura y experimentos. El objetivo es que entiendas el modelo de reactividad, porque son cinco funciones y un puente al DOM que cabe en una pantalla.

@preact/signals-core expone exactamente esto:

Función Qué hace
signal(valor) un contenedor de un valor que puede cambiar
computed(fn) un valor derivado, perezoso y memoizado
effect(fn) ejecuta fn y la vuelve a ejecutar cuando cambia algo que leyó
batch(fn) agrupa varias escrituras en una sola notificación
untracked(fn) ejecuta fn sin registrar las lecturas como dependencias

El mecanismo central es el rastreo automático de dependencias. Cuando un effect o un computed se ejecuta, la librería observa qué signals se leyeron durante esa ejecución y se suscribe solo a esos. No declaras dependencias, no hay arreglo que se te olvide actualizar.

const a = signal(1);
const b = signal(2);
const suma = computed(() => a.value + b.value);
effect(() => console.log("suma:", suma.value));
// imprime: suma: 3
a.value = 10;
// imprime: suma: 12
b.value = 2;
// no imprime nada: el valor no cambió

Esa última línea importa: los signals comparan antes de notificar. Escribir el mismo valor no dispara nada.

Objetivo: comprobar que solo se actualiza el nodo que cambió.

  1. Con make dev corriendo, abre las herramientas de desarrollo en la pestaña de elementos.

  2. Crea tres tareas y marca una como hecha.

  3. Observa qué nodos parpadean.

Qué deberías ver: solo el nodo de esa tarea. La lista no se vuelve a dibujar.

Cambia el filtro y mira otra vez: los nodos se reordenan y se ocultan, pero no se recrean. Eso es bindList reutilizando nodos por clave.

Objetivo: comprobar que el montaje de vistas está aislado correctamente.

  1. Escribe algo en el campo de nueva tarea, sin enviarlo.

  2. Sin tocar el campo, marca otra tarea como hecha.

Qué deberías ver: el texto que escribiste sigue ahí y no perdiste el foco.

Eso funciona porque main.ts envuelve el montaje en untracked. Sin eso, cambiar el estado remontaría la vista completa y perderías lo escrito en cada tecleo. Es un error fácil de cometer y difícil de diagnosticar, porque la app «funciona»: solo se comporta de forma extraña al escribir.

Paso 4.3 · Rompe la reactividad a propósito

Sección titulada «Paso 4.3 · Rompe la reactividad a propósito»

Objetivo: entender la trampa más sutil de este diseño.

En src/views/tablero.ts, dentro de item(), cambia la lectura del store por la del objeto capturado:

src/views/tablero.ts
scope.run(() => {
casilla.checked = buscar(tarea.id)?.done === true;
casilla.checked = tarea.done;
});

Qué deberías ver: la casilla deja de reflejar el estado. Puedes marcar una tarea, ver que el texto se tacha, y la casilla se queda como estaba.

La causa: bindList reutiliza el nodo por clave, así que el objeto tarea que se capturó al crear el nodo queda obsoleto en cuanto la tarea cambia. Y como tarea.done no es un signal, el efecto no tiene nada a lo que suscribirse.

Déjalo como estaba antes de seguir.

Objetivo: ver la diferencia entre agrupar escrituras y no hacerlo.

En src/state/tasks.ts, agrega un contador temporal al efecto de persistencia:

src/state/tasks.ts
let veces = 0;
effect(() => {
console.log("persistencias:", ++veces);
try {
localStorage.setItem(CLAVE, JSON.stringify(tareas.value));
} catch {}
});

Crea dos tareas, marca una como hecha, y pulsa «Borrar completadas» con el filtro en «hechas». Mira la consola.

Qué deberías ver: el contador sube de uno en uno, no de dos en dos, aunque limpiarHechas hace dos escrituras. Eso es batch.

Quita el batch de limpiarHechas y repítelo: ahora sube de dos en dos. Vuelve a ponerlo, y quita el contador.

El estado del dominio, completo
src/state/tasks.ts
/**
* Estado del dominio.
*
* Todo el estado de la aplicación vive aquí, en signals. Las vistas leen
* valores derivados y llaman a estas funciones: nunca mutan nada por su cuenta.
*/
import { batch, computed, effect, signal } from '@preact/signals-core';
export interface Task {
readonly id: string;
readonly title: string;
readonly done: boolean;
readonly createdAt: string;
}
export type Filtro = 'todas' | 'pendientes' | 'hechas';
export const FILTROS: readonly Filtro[] = ['todas', 'pendientes', 'hechas'];
const CLAVE = 'taskflow.v1';
// ------------------------------------------------------------------ estado
export const tareas = signal<readonly Task[]>(cargar());
export const filtro = signal<Filtro>('todas');
// --------------------------------------------------------------- derivados
/**
* Un computed es perezoso y se memoiza: solo se recalcula si cambia alguno de
* los signals que leyó, y solo si alguien lo está observando.
*/
export const visibles = computed<readonly Task[]>(() => {
const actual = filtro.value;
if (actual === 'todas') return tareas.value;
return tareas.value.filter((tarea) => tarea.done === (actual === 'hechas'));
});
export const pendientes = computed(
() => tareas.value.filter((tarea) => !tarea.done).length,
);
export const total = computed(() => tareas.value.length);
export const hayHechas = computed(() => tareas.value.some((tarea) => tarea.done));
export function buscar(id: string): Task | undefined {
return tareas.value.find((tarea) => tarea.id === id);
}
// ---------------------------------------------------------------- acciones
export function agregar(titulo: string): void {
const limpio = titulo.trim();
if (limpio === '') return;
const nueva: Task = {
id: crypto.randomUUID(),
title: limpio,
done: false,
createdAt: new Date().toISOString(),
};
tareas.value = [nueva, ...tareas.value];
}
export function alternar(id: string): void {
tareas.value = tareas.value.map((tarea) =>
tarea.id === id ? { ...tarea, done: !tarea.done } : tarea,
);
}
export function eliminar(id: string): void {
tareas.value = tareas.value.filter((tarea) => tarea.id !== id);
}
export function limpiarHechas(): void {
// batch agrupa las dos escrituras en una sola notificación, así los efectos
// se ejecutan una vez y no dos.
batch(() => {
tareas.value = tareas.value.filter((tarea) => !tarea.done);
if (filtro.value === 'hechas') filtro.value = 'todas';
});
}
// ------------------------------------------------------------ persistencia
/**
* Un solo efecto se encarga de persistir. Ninguna acción sabe que existe
* localStorage, y eso es justamente lo que hace fácil cambiarlo por una API en
* el Workshop 02.
*/
effect(() => {
try {
localStorage.setItem(CLAVE, JSON.stringify(tareas.value));
} catch {
// Modo privado o cuota llena: la app sigue funcionando en memoria.
}
});
function cargar(): readonly Task[] {
try {
const crudo = localStorage.getItem(CLAVE);
if (crudo === null) return [];
const dato: unknown = JSON.parse(crudo);
if (!Array.isArray(dato)) return [];
// Nunca confíes en lo que había guardado: el formato pudo cambiar entre
// versiones de la app, o alguien pudo editarlo a mano.
return dato.filter(esTask);
} catch {
return [];
}
}
function esTask(valor: unknown): valor is Task {
if (typeof valor !== 'object' || valor === null) return false;
const posible = valor as Record<string, unknown>;
return (
typeof posible['id'] === 'string' &&
typeof posible['title'] === 'string' &&
typeof posible['done'] === 'boolean' &&
typeof posible['createdAt'] === 'string'
);
}

Cuatro decisiones que merecen atención.

Las tareas son inmutables. Task tiene todas sus propiedades readonly, y alternar crea un objeto nuevo con map en lugar de mutar. Eso es lo que permite que la comparación de signals funcione: si mutaras el objeto en su lugar, la referencia sería la misma y nadie se enteraría.

visibles es un computed, no una función. Se memoiza: si lo lees tres veces y nada cambió, el filtro se ejecuta una sola vez. Y es perezoso: si nadie lo observa, no se calcula.

La persistencia es un solo effect. Ninguna acción sabe que existe localStorage. Eso es lo que va a hacer fácil cambiarlo por una llamada a la API en el workshop 02: se reemplaza ese efecto y nada más.

cargar() valida en lugar de confiar. Usa esTask en lugar de un as Task[]. El formato pudo cambiar entre versiones, o alguien pudo editar localStorage a mano. Un JSON.parse sin validar es una de las formas más comunes de que una aplicación se rompa después de un despliegue.

El puente al DOM, completo
src/lib/reactive.ts
/**
* El puente entre los signals y el DOM.
*
* Esto es todo lo que un framework hace por ti en materia de renderizado
* reactivo. Son unas cien líneas, no hay virtual DOM, y cada actualización
* toca exactamente el nodo que cambió.
*/
import { effect, untracked } from '@preact/signals-core';
export type Dispose = () => void;
/**
* Un ámbito agrupa los efectos de una vista para poder desecharlos todos
* cuando la vista se desmonta. Sin esto, cada cambio de ruta dejaría efectos
* vivos suscritos a signals: la fuga de memoria clásica de las SPA.
*/
export interface Scope {
/** Registra un efecto y devuelve su función de limpieza. */
run(fn: () => void): Dispose;
/** Registra una limpieza arbitraria (listeners, timers). */
onDispose(fn: Dispose): void;
/** Desecha todo lo registrado, en orden inverso. */
dispose(): void;
}
export function createScope(): Scope {
const disposers: Dispose[] = [];
return {
run(fn) {
const dispose = effect(fn);
disposers.push(dispose);
return dispose;
},
onDispose(fn) {
disposers.push(fn);
},
dispose() {
for (const dispose of disposers.splice(0).reverse()) dispose();
},
};
}
/** Mantiene el texto de un nodo sincronizado con un valor reactivo. */
export function bindText(scope: Scope, node: Node, value: () => string): void {
scope.run(() => {
node.textContent = value();
});
}
/** Mantiene un atributo sincronizado. Devolver null quita el atributo. */
export function bindAttr(
scope: Scope,
el: Element,
name: string,
value: () => string | null,
): void {
scope.run(() => {
const next = value();
if (next === null) el.removeAttribute(name);
else el.setAttribute(name, next);
});
}
/** Añade o quita una clase según una condición reactiva. */
export function bindClass(
scope: Scope,
el: Element,
name: string,
active: () => boolean,
): void {
scope.run(() => {
el.classList.toggle(name, active());
});
}
/**
* Reconcilia los hijos de un contenedor con una lista reactiva.
*
* Reutiliza los nodos existentes por clave, así que reordenar la lista mueve
* nodos en lugar de recrearlos, y el foco o el estado de un input dentro de un
* elemento sobreviven al reordenamiento.
*
* El algoritmo es correcto porque se recorre en el orden final: al llegar al
* índice i, los hijos 0..i-1 ya son los definitivos, de modo que insertBefore
* solo puede mover nodos hacia adelante.
*
* Cada elemento recibe su propio ámbito, que se desecha cuando el elemento sale
* de la lista. Sin eso, los efectos de una tarea borrada seguirían suscritos a
* los signals hasta que se desmontara la vista completa.
*/
export function bindList<T>(
scope: Scope,
container: Element,
items: () => readonly T[],
keyOf: (item: T) => string,
render: (item: T, itemScope: Scope) => Element,
): void {
const cache = new Map<string, { node: Element; scope: Scope }>();
scope.run(() => {
const next = items();
const vivas = new Set<string>();
next.forEach((item, index) => {
const key = keyOf(item);
vivas.add(key);
let entrada = cache.get(key);
if (entrada === undefined) {
const itemScope = createScope();
// untracked: construir el elemento puede leer signals, y esas
// lecturas no deben convertirse en dependencias de este efecto.
const node = untracked(() => render(item, itemScope));
entrada = { node, scope: itemScope };
cache.set(key, entrada);
}
const actual = container.children[index];
if (actual !== entrada.node) container.insertBefore(entrada.node, actual ?? null);
});
for (const [key, entrada] of cache) {
if (!vivas.has(key)) {
entrada.scope.dispose();
entrada.node.remove();
cache.delete(key);
}
}
});
scope.onDispose(() => {
for (const entrada of cache.values()) entrada.scope.dispose();
cache.clear();
});
}
type Hijo = Node | string;
/**
* Azúcar mínima para crear elementos. No es un motor de plantillas: crea el
* elemento, le pone atributos y le cuelga hijos.
*/
export function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs: Record<string, string> = {},
...children: Hijo[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
if (children.length > 0) el.append(...children);
return el;
}
/** Registra un listener y lo desregistra al desechar el ámbito. */
export function on<K extends keyof HTMLElementEventMap>(
scope: Scope,
el: EventTarget,
type: K,
handler: (event: HTMLElementEventMap[K]) => void,
): void {
const listener = handler as EventListener;
el.addEventListener(type, listener);
scope.onDispose(() => el.removeEventListener(type, listener));
}

Por qué existen los ámbitos. effect() devuelve una función que cancela la suscripción. Si no la llamas, el efecto vive para siempre. En una SPA eso es un problema concreto: cada cambio de ruta monta una vista nueva con sus propios efectos, y sin limpieza acabas con diez juegos de efectos suscritos a los mismos signals, actualizando nodos que ya no están en el documento.

El reconciliador de listas reutiliza los nodos por clave, así que reordenar mueve nodos en lugar de recrearlos. El algoritmo es correcto por una razón que no es obvia: se recorre en el orden final, así que al llegar al índice i los hijos 0..i-1 ya son los definitivos, y insertBefore solo puede mover nodos hacia adelante.

Cada elemento recibe además su propio ámbito, que se desecha cuando sale de la lista. Sin eso, los efectos de una tarea borrada seguirían vivos hasta que se desmontara la vista completa.

Cómo se usa en una vista
src/views/tablero.ts
import {
bindAttr,
bindClass,
bindList,
bindText,
h,
on,
type Scope,
} from '../lib/reactive';
import {
FILTROS,
agregar,
alternar,
buscar,
eliminar,
filtro,
hayHechas,
limpiarHechas,
pendientes,
total,
visibles,
type Filtro,
type Task,
} from '../state/tasks';
export function tablero(scope: Scope): HTMLElement {
const seccion = h('section', { class: 'tablero' });
const resumen = h('p', { class: 'resumen' });
bindText(scope, resumen, () =>
total.value === 0
? 'Todavía no hay tareas.'
: `${pendientes.value} pendientes de ${total.value}`,
);
seccion.append(h('h1', {}, 'Tablero'), resumen, formulario(scope), filtros(scope));
const lista = h('ul', { class: 'lista' });
bindList(
scope,
lista,
() => visibles.value,
(tarea) => tarea.id,
(tarea, itemScope) => item(tarea, itemScope),
);
const vacio = h('p', { class: 'vacio' }, 'Nada que mostrar con este filtro.');
bindClass(scope, vacio, 'oculto', () => visibles.value.length > 0);
const limpiar = h('button', { type: 'button', class: 'enlace' }, 'Borrar completadas');
on(scope, limpiar, 'click', () => limpiarHechas());
bindClass(scope, limpiar, 'oculto', () => !hayHechas.value);
seccion.append(lista, vacio, limpiar);
return seccion;
}
function formulario(scope: Scope): HTMLFormElement {
const campo = h('input', {
type: 'text',
class: 'campo',
placeholder: '¿Qué hay que hacer?',
'aria-label': 'Título de la nueva tarea',
autocomplete: 'off',
maxlength: '120',
});
const form = h(
'form',
{ class: 'nueva' },
campo,
h('button', { type: 'submit' }, 'Agregar'),
);
on(scope, form, 'submit', (event) => {
event.preventDefault();
agregar(campo.value);
campo.value = '';
campo.focus();
});
return form;
}
function filtros(scope: Scope): HTMLElement {
const grupo = h('div', {
class: 'filtros',
role: 'group',
'aria-label': 'Filtrar tareas',
});
for (const opcion of FILTROS) {
const boton = h('button', { type: 'button', class: 'filtro' }, etiqueta(opcion));
bindClass(scope, boton, 'activo', () => filtro.value === opcion);
// aria-pressed comunica el estado a lectores de pantalla: la clase CSS
// sola solo lo comunica a quien puede ver el color.
bindAttr(scope, boton, 'aria-pressed', () => String(filtro.value === opcion));
on(scope, boton, 'click', () => {
filtro.value = opcion;
});
grupo.append(boton);
}
return grupo;
}
function item(tarea: Task, scope: Scope): HTMLElement {
const li = h('li', { class: 'tarea' });
const casilla = h('input', {
type: 'checkbox',
'aria-label': `Marcar «${tarea.title}»`,
});
// Importante: el estado se lee del store por id, no del objeto `tarea` que
// se capturó al crear el nodo. bindList reutiliza los nodos por clave, así
// que ese objeto queda obsoleto en cuanto la tarea cambia.
scope.run(() => {
casilla.checked = buscar(tarea.id)?.done === true;
});
on(scope, casilla, 'change', () => alternar(tarea.id));
const enlace = h('a', {
class: 'titulo',
href: `/tareas/${tarea.id}`,
'data-link': '',
});
bindText(scope, enlace, () => buscar(tarea.id)?.title ?? tarea.title);
bindClass(scope, li, 'hecha', () => buscar(tarea.id)?.done === true);
const borrar = h(
'button',
{ type: 'button', class: 'borrar', 'aria-label': `Borrar «${tarea.title}»` },
'×',
);
on(scope, borrar, 'click', () => eliminar(tarea.id));
li.append(casilla, enlace, borrar);
return li;
}
function etiqueta(opcion: Filtro): string {
switch (opcion) {
case 'todas':
return 'Todas';
case 'pendientes':
return 'Pendientes';
case 'hechas':
return 'Hechas';
}
}

Mira el patrón que se repite: nunca se lee tareas.value directamente al construir un elemento. Siempre se pasa una función a bindText, bindClass o bindList, y esa función es la que se vuelve a ejecutar cuando algo cambia.

Y la accesibilidad no es un añadido: bindClass marca el filtro activo con una clase, pero bindAttr también actualiza aria-pressed. La clase comunica el estado a quien puede ver el color, el atributo lo comunica a un lector de pantalla. Las casillas y los botones de borrar llevan aria-label con el título de la tarea, porque «×» no le dice nada a nadie que no esté viendo la pantalla.

Cinco funciones, rastreo automático de dependencias, y un puente al DOM de cien líneas. Eso es todo lo que hace un framework en materia de renderizado reactivo.

Las dos trampas que vas a encontrar si escribes algo así por tu cuenta: olvidar limpiar los efectos al desmontar, y leer valores capturados en lugar del origen de verdad.

Ahora el router, que es lo que va a romper en S3.