wandres.dev
INTEGRAR LIBRERÍAS · interoperar con JS

Crear tus propios primitivos reactivos

El patron canonico para fabricar primitivos reactivos reutilizables que envuelven una fuente externa: un signal interno que hace de espejo, una suscripcion a la fuente que lo escribe, un onCleanup que corta la suscripcion atado al dueno, y una API que devuelve solo el getter cuando la autoridad del valor es externa. Por que un accessor de solo lectura comunica intencion, por que el prefijo create anuncia ciclo de vida, y como hacer el primitivo reactivo a sus propios argumentos re-suscribiendose dentro de un createEffect con onCleanup anidado.

⏱ 17 min

Ya has envuelto fuentes externas con from y puenteado stores con reconcile. El siguiente escalón es empaquetar esos puentes en primitivos propios: funciones reutilizables con la misma forma y las mismas garantías que las que Solid trae de fábrica. El patrón es siempre el mismo cuarteto —un signal interno que espeja la fuente, una suscripción que lo alimenta, un onCleanup que la corta atado al dueño, y una API que expone solo lo justo—. Dominarlo es dejar de usar Solid para empezar a ampliarlo, hablando el mismo idioma que la librería.

🎯 Al terminar esta lección sabrás
  • Interiorizar el cuarteto del patrón: signal interno, suscripción, onCleanup y API de getter.
  • Devolver un accessor de solo lectura cuando la autoridad del valor es externa, no un [get, set].
  • Respetar la convención de nombres create* como anuncio de un recurso con ciclo de vida.
  • Hacer el primitivo reactivo a sus argumentos re-suscribiéndose dentro de un createEffect con onCleanup anidado.

El cuarteto: signal, suscripción, limpieza, getter

Un primitivo reactivo que envuelve una fuente externa se construye siempre igual. Creas un signal interno que hará de espejo del valor de la fuente; te suscribes a la fuente para escribir ese signal en cada cambio; registras un onCleanup que deshace la suscripción cuando el dueño muere; y devuelves solo el getter. El ejemplo canónico es una consulta de medios reactiva:

import { createSignal, onCleanup, type Accessor } from "solid-js";

function createMediaQuery(consulta: string): Accessor<boolean> {
  const mql = window.matchMedia(consulta);
  const [coincide, setCoincide] = createSignal(mql.matches); // signal interno = espejo
  const onChange = () => setCoincide(mql.matches);           // la fuente lo escribe
  mql.addEventListener("change", onChange);
  onCleanup(() => mql.removeEventListener("change", onChange)); // corta al morir el dueno
  return coincide;                                            // API: solo el getter
}

El mismo esqueleto sirve para envolver un setInterval, un ResizeObserver, la posición del ratón o el estado de la conexión. Cambia la fuente y su forma de suscribirse; el cuarteto no se mueve.

function createTick(ms: number): Accessor<number> {
  const [n, setN] = createSignal(0);
  const id = setInterval(() => setN((x) => x + 1), ms);
  onCleanup(() => clearInterval(id));
  return n;
}

Solo el getter: la API comunica quién manda

La decisión de diseño más reveladora es qué devuelves. Cuando el valor lo gobierna una fuente externa —el navegador decide si la media query coincide, el reloj decide el tick—, el consumidor no tiene ninguna autoridad para escribirlo, así que exponer un setter sería mentir sobre el contrato. Por eso estos primitivos devuelven un Accessor<T> desnudo, de solo lectura. La firma se convierte en documentación: quien la lee entiende de un vistazo que puede observar el valor pero no fijarlo.

El contraste aclara la regla. Un primitivo como createStoredSignal —que viste en el nivel de disposición— devuelve la tupla [get, set] porque ahí el consumidor es dueño de las escrituras: él decide el tema, y el primitivo se limita a persistirlo. La heurística es nítida: devuelve una tupla cuando el consumidor manda sobre el valor; devuelve solo el getter cuando manda una fuente de fuera. La forma de la API codifica la dirección de la autoridad.

flowchart LR
X[fuente externa media query reloj observer] -->|suscripcion| S[signal interno espejo]
S -->|getter de solo lectura| C[consumidor observa]
D[dueno reactivo] -->|onCleanup| U[corta la suscripcion al desmontar]
style S fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b
style U fill:#f38ba8,color:#11111b

Reactivo a sus propios argumentos

El createMediaQuery de arriba tiene un límite: fija la consulta una vez. Un primitivo maduro acepta que sus entradas también cambien —que la consulta llegue como un accessor y varíe en el tiempo—. La técnica es envolver la suscripción en un createEffect y poner el onCleanup dentro del efecto. Ahí está la joya del sistema de dueños: un onCleanup anidado en un efecto corre antes de cada re-ejecución y también al disponerse, de modo que la re-suscripción es automática y no filtra nada.

import { createSignal, createEffect, onCleanup, type Accessor } from "solid-js";

function createMediaQuery(consulta: Accessor<string> | string): Accessor<boolean> {
  const leer = typeof consulta === "function" ? consulta : () => consulta;
  const [coincide, setCoincide] = createSignal(false);

  createEffect(() => {
    const mql = window.matchMedia(leer());        // re-lee si la consulta cambia
    setCoincide(mql.matches);
    const onChange = () => setCoincide(mql.matches);
    mql.addEventListener("change", onChange);
    onCleanup(() => mql.removeEventListener("change", onChange)); // corre en cada vuelta
  });

  return coincide;
}

Cuando la consulta cambia, el efecto se re-ejecuta: su onCleanup retira el listener viejo, el cuerpo se suscribe a la media query nueva, y el signal espejo se actualiza. Un solo mecanismo —el efecto con su limpieza anidada— cubre a la vez el cambio de argumento y la baja final. Este es el patrón que, escalado, sostiene toda la colección oficial de primitivos.

Enriquecer la API y componer primitivos

El getter desnudo es el caso más simple, pero un primitivo puede devolver una forma más rica cuando el valor lo pide. Una posición de ratón son dos números que cambian juntos, así que su primitivo natural devuelve un accessor a un objeto { x, y }, no dos accessores sueltos que se actualicen a destiempo.

import { createSignal, onCleanup, type Accessor } from "solid-js";

function createMousePosition(): Accessor<{ x: number; y: number }> {
  const [pos, setPos] = createSignal({ x: 0, y: 0 });
  const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
  window.addEventListener("mousemove", onMove);
  onCleanup(() => window.removeEventListener("mousemove", onMove));
  return pos;
}

Y como un primitivo no es más que una función, compone: uno puede envolver el accessor de otro para apilarle una política, igual que compones funciones puras. Un createDebounced toma cualquier accessor y devuelve otro que se retrasa, sin saber ni importarle de dónde salió el original.

import { createSignal, createEffect, onCleanup, type Accessor } from "solid-js";

function createDebounced<T>(fuente: Accessor<T>, ms: number): Accessor<T> {
  const [eco, setEco] = createSignal(fuente());
  createEffect(() => {
    const v = fuente();                       // rastrea la fuente
    const id = setTimeout(() => setEco(() => v), ms);
    onCleanup(() => clearTimeout(id));        // cancela el pendiente en cada cambio
  });
  return eco;
}

// apila politicas: la posicion del raton, pero antirebotada
const posLenta = createDebounced(createMousePosition(), 100);

Cada capa añade una política y delega el resto en la de abajo. Esa capacidad de encadenar primitivos como quien encadena funciones es lo que convierte cuatro líneas de patrón en un lenguaje entero, y su único límite es tu imaginación, no la librería.

🪞

Signal espejo

Un createSignal interno guarda el ultimo valor que la fuente empujo, para que el grafo lo lea sin conocer la fuente.

🧹

onCleanup atado

La baja de la suscripcion se ata al dueno, dentro del efecto si el primitivo es reactivo a sus argumentos.

🔍

API minima

Devuelve solo el getter cuando la autoridad es externa, la tupla completa cuando el consumidor escribe.

💡
El prefijo create no es decoración: es un contrato

Nombrar createAlgo a tu primitivo no es seguir una moda estética. El prefijo create comunica que la función abre recursos con ciclo de vida y por tanto debe llamarse dentro de un dueño reactivo, igual que createEffect o createMemo. Reserva make* —la otra convención del ecosistema— para helpers imperativos que devuelven una función de baja y no rastrean nada. El nombre, bien elegido, documenta el contrato de uso sin una línea de comentario y avisa a tu equipo de dónde puede y dónde no puede invocarlo.

⚠️
Sin dueño no hay onCleanup: envuelve en createRoot si hace falta

Todo el patrón depende de que exista un dueño al que onCleanup pueda engancharse. Dentro de un componente lo hay y la limpieza corre al desmontar. Pero si llamas al primitivo al nivel de módulo, no hay dueño y el onCleanup no se registra en ningún sitio: el listener queda vivo para siempre. Si necesitas un primitivo de larga vida fuera de todo componente, créalo dentro de un createRoot que le dé un dueño estable y guarda su dispose para el día que quieras cortarlo.

Un primitivo reactivo es una fuente externa vestida de signal, con su vida atada al dueño

El salto mental que convierte a alguien que usa Solid en alguien que lo extiende es dejar de ver createSignal, createMemo y compañía como un vocabulario cerrado y empezar a verlos como los átomos con los que se compone un vocabulario propio. Un primitivo que envuelve una fuente externa no tiene misterio una vez que ves su anatomía: por dentro hay un signal que hace de espejo, porque el grafo solo sabe leer signals y hay que darle uno que reflejar; hay una suscripción a la fuente que escribe ese espejo, porque el valor real vive fuera y alguien tiene que traerlo; hay un onCleanup que corta la suscripción, porque todo recurso que abres debe cerrarse y su cierre pertenece al dueño que ejecuta el primitivo, no a ti; y hay una API que expone solo lo que el consumidor tiene derecho a tocar, un getter desnudo cuando la autoridad del valor está fuera. La pieza que separa al primitivo de juguete del primitivo de producción es hacerlo reactivo a sus propios argumentos, y la técnica es de una elegancia que merece grabarse: metes la suscripción en un createEffect y el onCleanup dentro del efecto, y entonces un único mecanismo resuelve a la vez dos problemas que parecían distintos —la re-suscripción cuando el argumento cambia y la baja definitiva cuando el dueño muere—, porque la limpieza anidada corre en ambas transiciones. Cuando este cuarteto se te vuelve reflejo, dejas de preguntarte “cómo consigo tal comportamiento con signals” y empiezas a preguntarte “qué primitivo lo encapsula”, y con esa pregunta construyes abstracciones que tu equipo consume como si vinieran firmadas por la propia librería. Ese es, además, el patrón exacto que industrializó la colección oficial que verás a continuación: no aprendes un truco, aprendes el molde del que salen todos.

⚔️ Fabrica tres primitivos con el mismo molde
  1. Escribe createMediaQuery con el cuarteto completo y úsalo para reaccionar a (prefers-color-scheme: dark); confirma que devuelve solo el getter.
  2. Escribe createTick sobre setInterval y monta y desmonta el componente varias veces para verificar que clearInterval corre y no se acumulan relojes.
  3. Convierte createMediaQuery en reactivo a su argumento aceptando un Accessor<string>; cambia la consulta y comprueba que el listener viejo se retira y el nuevo se registra.
  4. Devuelve a propósito una tupla [get, set] desde createTick y argumenta por qué es una mala API cuando la autoridad del valor es el reloj.
  5. Llama a un primitivo al nivel de módulo, detecta que el onCleanup no se engancha, y arréglalo envolviéndolo en un createRoot con su dispose.