Saltar al contenido

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.

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.

TSX
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.

TSX
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.

TSX
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é.

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:

TSX
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.

TSX
// Componer sobre createStoredSignal: persiste y ademas retrasa la escriturafunction 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.