wandres.dev
SIGNALS AVANZADOS · equals y opciones

createStoredSignal: un primitivo propio con persistencia

Envolver createSignal en un primitivo reutilizable que persiste en localStorage: lee el valor inicial del almacenamiento, intercepta cada escritura para serializar, sincroniza entre pestañas con un listener de storage y se limpia solo con onCleanup. El patrón para construir tus propios primitivos reactivos.

⏱ 17 min

Los primitivos que Solid te da —createSignal, createMemo, createEffect— no son una lista cerrada, son una base. Un primitivo propio es solo una función que envuelve a los oficiales y les añade una política: persistencia, validación, antirrebote, sincronización. Aprender a construirlos es el salto de usar Solid a extender Solid, y el ejemplo canónico es un signal que se guarda solo en localStorage.

🎯 Al terminar esta lección sabrás
  • Comprender qué es un primitivo propio: una closure sobre un signal.
  • Construir createStoredSignal que lea e inicialice desde localStorage.
  • Interceptar el setter para serializar sin romper el actualizador funcional.
  • Sincronizar entre pestañas y liberar el listener con onCleanup.

Anatomía de un primitivo propio

Un primitivo propio respeta el contrato de forma del que envuelve. Como createSignal devuelve una tupla [getter, setter], tu createStoredSignal devolverá exactamente esa misma tupla: así es un reemplazo directo, intercambiable sin que el código que lo consume note la diferencia. Por dentro crea un signal normal y le añade comportamiento capturado en la closure.

La convención de nombres no es casual: los primitivos reactivos de Solid empiezan por create, y respetarla comunica de un vistazo que tu función crea recursos reactivos con ciclo de vida y debe llamarse dentro de un owner, no en cualquier parte. Un createStoredSignal, un createDebouncedSignal, un createMediaQuery: todos comparten la firma de devolver accesores y todos declaran, por su prefijo, que participan del sistema de dueños. Nombrar bien un primitivo es documentar su contrato sin escribir una línea de comentario.

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

function createStoredSignal<T>(
  clave: string,
  inicial: T,
  serde = { parse: JSON.parse, stringify: JSON.stringify },
): Signal<T> {
  const previo =
    typeof localStorage !== "undefined" ? localStorage.getItem(clave) : null;

  const [valor, setValor] = createSignal<T>(
    previo !== null ? (serde.parse(previo) as T) : inicial,
  );
  // ... el setter y la sincronizacion van aqui ...
  return [valor, setValor];
}

La guarda typeof localStorage !== "undefined" no es paranoia: en SSR —SolidStart renderizando en el servidor— localStorage no existe, y leerlo sin protección revienta el render. Un primitivo bien hecho es consciente del entorno donde puede correr.

createStoredSignal: leer, escribir, serializar

El corazón está en interceptar el setter. Cada escritura debe serializar el valor en el almacenamiento además de actualizar el signal. Pero aquí acecha la trampa clásica: el setter de Solid acepta o un valor, o una función actualizadora prev => next. Antes de serializar tienes que resolver cuál de las dos formas te llegó, porque a localStorage va el resultado, no la función.

const set = ((nuevo: unknown) => {
  const resuelto =
    typeof nuevo === "function"
      ? (nuevo as (prev: T) => T)(valor()) // resuelve el actualizador
      : (nuevo as T);
  localStorage.setItem(clave, serde.stringify(resuelto));
  return setValor(() => resuelto); // envuelve en () => para valores que son funciones
}) as Setter<T>;

Dos sutilezas de nivel avanzado conviven en esas líneas. La primera: resolvemos el actualizador nosotros llamándolo con valor(), para tener el resultado concreto que serializar. La segunda: al reenviar a setValor usamos () => resuelto, la forma que evita que Solid vuelva a interpretar un resuelto que resulte ser una función como si fuera otro actualizador. El as Setter<T> reconoce con honestidad que el tipo Setter de Solid está sobrecargado y no se deja construir a mano sin un empujón.

Un primitivo de producción también asume que el almacenamiento es hostil. localStorage puede contener JSON corrupto de una versión anterior de tu app, y parse lanzará; puede estar lleno y setItem lanzará por cuota excedida; puede estar deshabilitado en modo privado. Un createStoredSignal robusto envuelve la lectura inicial en un try/catch que cae al valor por defecto ante datos ilegibles, y protege la escritura para que un fallo de persistencia no tumbe la actualización del signal en memoria —persistir es un efecto secundario deseable, no una precondición de que el estado de la UI avance—. La regla es que un fallo de almacenamiento degrade la persistencia, nunca la reactividad.

Sincronizar entre pestañas y limpiar

El navegador dispara un evento storage en las demás pestañas cuando una escribe en localStorage. Suscribirte a él convierte tu primitivo en un estado sincronizado entre pestañas casi gratis. Pero todo listener que registras es una fuga en potencia: hay que retirarlo cuando el dueño reactivo muera, y para eso está onCleanup.

const onStorage = (e: StorageEvent) => {
  if (e.key === clave && e.newValue !== null) {
    setValor(() => serde.parse(e.newValue) as T);
  }
};
window.addEventListener("storage", onStorage);
onCleanup(() => window.removeEventListener("storage", onStorage));

onCleanup ata la vida del listener al owner que ejecuta el primitivo: cuando el componente se desmonta —o el createRoot que lo posee se dispone— el listener se retira solo. Esto implica una regla de uso: createStoredSignal debe llamarse dentro de un contexto reactivo con dueño. Si lo invocas al nivel de módulo, envuélvelo en createRoot para darle uno de larga vida, o el onCleanup no tendrá a quién engancharse.

Esta es la diferencia entre un signal y una computación que la lección de estado global ya insinuaba: un createSignal no necesita dueño, pero el onCleanup sí, porque libera un recurso y alguien tiene que decidir cuándo. Un primitivo que abre recursos externos hereda esa exigencia: deja de ser un valor libre y pasa a ser una computación con ciclo de vida. Ignorarlo produce el bug más silencioso de todos —listeners que se acumulan montaje tras montaje— hasta que la aplicación responde con retraso y nadie sabe por qué.

flowchart LR
S[signal valor] -->|set serializa| L[localStorage clave]
L -->|evento storage otra pestana| S
style S fill:#89b4fa,color:#11111b
style L fill:#f9e2af,color:#11111b

Reutilizar como un primitivo de verdad

Con las tres piezas ensambladas —lectura inicial, setter interceptado, sincronización con limpieza— el uso es indistinguible de un createSignal normal, y esa es toda la gracia:

function Ajustes() {
  const [tema, setTema] = createStoredSignal("tema", "oscuro");
  return (
    <button onClick={() => setTema((t) => (t === "oscuro" ? "claro" : "oscuro"))}>
      Tema actual: {tema()}
    </button>
  );
}

El componente no sabe ni le importa que el valor viaje a localStorage y vuelva sincronizado entre pestañas: ve una tupla [getter, setter] como cualquier otra. Y como es una función, compone: puedes envolver createStoredSignal en otro primitivo que le añada antirrebote, o validación de esquema, o cifrado, apilando políticas sin tocar el consumidor.

// Componer sobre createStoredSignal: persiste y ademas retrasa la escritura
function createDebouncedStored<T>(clave: string, inicial: T, ms: number) {
  const [valor, setValor] = createStoredSignal<T>(clave, inicial);
  let id: ReturnType<typeof setTimeout>;
  onCleanup(() => clearTimeout(id));
  const setDebounced = (v: T) => {
    clearTimeout(id);
    id = setTimeout(() => setValor(() => v), ms); // solo persiste al reposar
  };
  return [valor, setDebounced] as const;
}

Cada capa añade una política y delega el resto en la de abajo: el antirrebote decide cuándo escribir, createStoredSignal decide dónde, y createSignal guarda el valor. Apilar primitivos así es el equivalente reactivo de componer funciones puras, y su límite lo pone tu imaginación, no la librería.

Queda un matiz de producción que un ingeniero riguroso no pasa por alto: la hidratación. En SSR, el servidor renderiza con el valor inicial —no tiene localStorage— y el cliente hidrata leyendo el almacenamiento; si difieren, el primer fotograma parpadea del valor del servidor al persistido. La solución honesta no es esconder el problema sino decidirlo: o aceptas ese parpadeo como coste de la persistencia por cliente, o mueves la lectura a un punto posterior a la hidratación, o sirves el valor desde una cookie que el servidor sí ve. El primitivo no borra el problema del entorno; te da un lugar único y honesto donde encararlo.

💡
Parametriza la serialización con un serde

JSON no cubre Date, Map, Set ni BigInt. Al aceptar un objeto serde con parse y stringify, tu primitivo se abre a cualquier codificación sin reescribirlo: un serde que convierte fechas a ISO y de vuelta, uno que usa un esquema de validación que rechaza datos corruptos del almacenamiento, o uno que comprime. Inyectar la serialización en lugar de cablearla es lo que separa un primitivo reutilizable de un truco de un solo uso.

Un primitivo es una closure sobre un signal que le añade una política

El error de quien empieza es creer que createSignal, createMemo y createEffect son un vocabulario fijo que hay que aceptar tal cual; el ingeniero maduro los ve como bloques de construcción sobre los que erige su propio lenguaje reactivo. Un primitivo propio no es magia: es una función que crea un signal por dentro, lo guarda en una closure y expone una interfaz enriquecida —normalmente la misma tupla [getter, setter], para ser un reemplazo directo— habiendo interpuesto una política entre el mundo y el estado. En createStoredSignal esa política es la persistencia, y para implementarla bien tienes que dominar tres cosas que separan al aficionado del profesional. La primera es el ciclo de vida: todo recurso externo que abres —un listener, un intervalo, una suscripción— debe cerrarse con onCleanup, atado al owner que ejecuta el primitivo, o acumularás fugas que nadie diagnostica hasta que la aplicación se arrastra. La segunda es la fidelidad al contrato del setter: si Solid acepta actualizadores funcionales, tu envoltura también debe aceptarlos, resolviéndolos antes de actuar sobre ellos, o romperás en silencio el código que los use. La tercera es la conciencia del entorno: un primitivo que toca APIs del navegador debe sobrevivir al servidor donde esas APIs no existen. Quien interioriza este patrón deja de preguntarse cómo hago X con signals y empieza a preguntarse qué primitivo encapsula X, y con esa pregunta construye abstracciones que su equipo usa como si vinieran de la propia librería.

⚔️ Construye tu primer primitivo
  1. Implementa createStoredSignal completo y úsalo para un tema oscuro/claro; recarga la página y confirma que persiste.
  2. Rompe a propósito el manejo del actualizador funcional serializando nuevo sin resolverlo; observa qué basura acaba en localStorage al pasar prev => ....
  3. Abre dos pestañas de la misma página y cambia el valor en una; verifica que la otra se actualiza por el evento storage.
  4. Registra el listener sin onCleanup, monta y desmonta el componente varias veces y detecta la acumulación de listeners duplicados.
  5. Inyecta un serde que serialice un Date a ISO y lo reconstruya al leer; demuestra que el tipo sobrevive al viaje por localStorage.