Guía
Reactividad
signal, memo y effect son todo el modelo. Cómo se suscriben solos, cuándo se ejecutan y cómo se liberan.
Tres primitivos
| Qué es | Tiene valor | Se recalcula | |
|---|---|---|---|
signal |
Estado. Lo único que se escribe. | Sí | Nunca |
memo |
Un valor derivado de otros. | Sí | Perezosamente, al leerlo |
effect |
Trabajo con efectos: tocar el DOM, un fetch, un log. |
No | Tras la escritura que lo afecta |
import { effect, memo, signal } from "ascua";
const precio = signal(1000);
const cantidad = signal(3);
const total = memo(() => precio() * cantidad());
effect(() => console.log(`Total: ${total()}`)); // Total: 3000
cantidad.set(4); // Total: 4000Nadie declaró que total depende de precio y cantidad, ni que el efecto
depende de total. Se descubre leyendo: mientras un memo o un efecto se
ejecuta, el runtime anota qué leyó. Si la próxima vez lee otras cosas —una rama
de un if que cambió—, se suscribe a esas y deja de escuchar a las anteriores.
signal
const nombre = signal("Ana");
nombre(); // leer, y suscribirse si hay alguien escuchando
nombre.set("Luis"); // escribir
nombre.update((n) => n.trim()); // escribir a partir del valor actual
nombre.peek(); // leer sin suscribirseEscribir el mismo valor que ya tenía (Object.is) no despierta a nadie.
Un signal guarda una referencia: mutar un array o un objeto por dentro no avisa. Se escribe uno nuevo:
const tareas = signal<Tarea[]>([]);
tareas.update((lista) => [...lista, nueva]); // ✓ avisa
tareas().push(nueva); // ✗ nadie se enteramemo
const visibles = memo(() => tareas().filter((t) => !t.hecha));Un memo es perezoso: no calcula hasta que alguien lo lee, y no vuelve a calcular mientras sus fuentes no cambien. Y corta la propagación: si al recalcular obtiene el mismo valor, quien lo lee no se entera.
const esGrande = memo(() => cuenta() > 100);
// cuenta: 1 → 2 → 3 … → 100: esGrande sigue siendo false y nadie repinta.El segundo argumento cambia qué se considera «el mismo valor»:
const punto = memo(() => ({ x: x(), y: y() }), (a, b) => a.x === b.x && a.y === b.y);effect
effect(() => {
document.title = `${pendientes()} pendientes`;
});Se ejecuta una vez al crearse y otra por cada cambio que le afecte. En una
plantilla casi nunca se escribe a mano: cada ${() => …} es un efecto que
el compilador pone por ti.
Limpieza
effect(() => {
const id = setInterval(tick, intervalo());
onCleanup(() => clearInterval(id));
});onCleanup corre antes de cada reejecución y una última vez cuando el efecto
se libera. Es la forma de soltar lo que se adquirió: un intervalo, un listener
global, una suscripción.
Sin estados intermedios
Escribir en un signal no ejecuta nada: marca. Primero se marca todo lo que depende de él, y solo cuando la marca terminó de propagarse corren los efectos. De ahí salen dos garantías:
- Un efecto nunca ve un estado a medias. Si
bycderivan deay un efecto lee los dos, al cambiarael efecto corre una vez y ve los dos valores nuevos, nunca uno nuevo y otro viejo. - Un efecto corre una vez por cambio, aunque le lleguen avisos por varios caminos.
batch
Varias escrituras seguidas son varios cambios. batch las junta en uno:
batch(() => {
precio.set(1200);
cantidad.set(2);
});
// Total: 2400 — una sola vez, no dos.untrack
Leer sin suscribirse, cuando un efecto necesita un dato pero no debe reaccionar a él:
effect(() => {
registrar(pagina(), untrack(() => usuario())); // solo reacciona a pagina
});Quién es dueño de qué
Todo lo reactivo tiene un dueño: el scope que estaba activo al crearlo. Liberar un dueño libera todo lo que cuelga de él, en orden.
const [app, desmontar] = root(() => {
const cuenta = signal(0);
effect(() => console.log(cuenta()));
return cuenta;
});
desmontar(); // el efecto deja de existir y sus onCleanup correnNo hace falta llamar a root casi nunca: mount, <Show>, <For> y el
router crean los suyos. Cuando una rama de <Show> se cierra, todo lo que se
creó dentro —efectos, memos, listeners— se libera con ella. Nada queda vivo
escuchando a un componente que ya no está.
Cuando un efecto se reejecuta, antes de correr libera lo que creó la vez anterior: un efecto creado dentro de otro efecto no se acumula.
Código asíncrono
Después de un await, el scope ya no es el que era: el código corre en otra
vuelta del bucle de eventos. Si hay que crear algo reactivo ahí, se captura el
scope antes y se restaura:
const scope = currentScope();
const datos = await fetch(url).then((r) => r.json());
withScope(scope, () => {
effect(() => …); // pertenece al componente, y se libera con él
});selector: elegir uno entre mil
El caso es la fila seleccionada de una tabla. Si cada fila lee el signal de la
selección, cambiarla despierta mil efectos para repintar dos. Con selector,
cada fila se suscribe solo a su clave:
const elegida = signal<number | null>(null);
const esLaElegida = selector(() => elegida());
view`<tr class:seleccionada=${() => esLaElegida(fila.id)}>…</tr>`;Cambiar elegida avisa a la fila que deja de estarlo y a la que pasa a
estarlo. Las otras 998 ni se enteran.
Errores
Un error dentro de un efecto sube por los dueños hasta el primero que tenga un
onError. Está en Errores y depuración.