El patrón factory createXStore()
En vez de esparcir signals y acciones sueltas por un módulo, una función factory createXStore() encapsula el estado y su lógica dentro y devuelve una API limpia y tipada. Ese pliegue separa la definición del store de su instanciación: el mismo código puede producir un singleton de cliente o una instancia por petición, y TypeScript deriva el tipo de la API sin que lo escribas dos veces.
Un store de módulo con signals y acciones sueltas funciona, pero no compone: el estado, su lógica y su instanciación están soldados al archivo. El patrón factory da el siguiente paso de madurez. Una función createContadorStore() construye el estado y toda su lógica dentro de su cuerpo y devuelve un objeto que es su API pública —getters, acciones, quizá un dispose—. Con ese pliegue ganas tres cosas de golpe: encapsulación real, un tipo que TypeScript deriva solo, y la separación entre definir un store y crearlo, que es la costura sobre la que se construye todo lo demás en este nivel.
- Encapsular estado y lógica en una función factory que devuelva una API limpia.
- Tipar la API con
ReturnTypeen lugar de duplicar el tipo a mano. - Distinguir definir un store de instanciarlo, y por qué esa separación importa.
- Elegir entre exportar una única instancia (singleton) o la factory misma (una por consumidor).
De variables sueltas a una fábrica
Compara los dos estilos. En el primero, el estado global es un puñado de exportaciones que comparten un módulo por casualidad de vecindad. En el segundo, hay una función que produce un objeto cohesionado, y todo lo privado —los signals, el setter— queda cerrado dentro del closure.
// store/contador.ts
import { createRoot, createSignal, createMemo } from "solid-js";
export function createContadorStore() {
return createRoot((dispose) => {
const [cuenta, setCuenta] = createSignal(0);
const doble = createMemo(() => cuenta() * 2);
return {
get cuenta() { return cuenta(); }, // getter: lectura reactiva
get doble() { return doble(); },
incrementar: () => setCuenta((n) => n + 1),
reiniciar: () => setCuenta(0),
dispose,
};
});
}
Todo lo mutable —setCuenta— vive en el closure y nunca escapa. La factory devuelve una API cuya forma tú decides: cuenta y doble como getters de solo lectura, incrementar y reiniciar como verbos, y dispose como interruptor. Quien la consume ve un objeto limpio, no un revoltijo de tuplas de signals. La reactividad viaja intacta porque los getters llaman a los signals al leerse: acceder a store.cuenta dentro de un efecto o del JSX suscribe a ese signal, exactamente como si lo llamaras a mano.
const { cuenta } = store copia el valor actual del getter una sola vez y lo congela: pierdes la reactividad, igual que al desestructurar props. La regla es la misma de siempre en Solid: lee store.cuenta en el punto de uso, no lo extraigas antes. Si necesitas pasar la lectura por ahí, pasa una función —() => store.cuenta— no el valor. El getter es azúcar sobre una llamada a signal, y hereda todas sus reglas de rastreo.
Una API limpia y tipada
El segundo regalo del patrón es el tipado, y es casi gratis. En lugar de declarar a mano una interfaz que describa la API —y mantenerla sincronizada con la implementación—, dejas que TypeScript derive el tipo del valor de retorno de la factory con ReturnType.
export type ContadorStore = ReturnType<typeof createContadorStore>;
Ahora ContadorStore es exactamente la forma que devuelve la factory: si añades una acción o cambias un getter, el tipo se actualiza solo, sin que puedan divergir la interfaz declarada y la real. Ese tipo derivado es lo que anotarás en un contexto, en la firma de una función que reciba el store, o en el valor de un createContext.
import { createContext } from "solid-js";
import { createContadorStore, type ContadorStore } from "./store/contador";
// El contexto habla en terminos del tipo derivado, nunca desactualizado.
export const ContadorCtx = createContext<ContadorStore>();
Merece subrayar lo que se gana con ReturnType: la API y su tipo no pueden desincronizarse, porque son la misma fuente. En un diseño con la interfaz escrita a mano es habitual añadir un método a la implementación y olvidar el tipo, o al revés, y el compilador calla porque cada uno es válido por separado. Con el tipo derivado ese desfase es imposible por construcción: si el método existe en el retorno, existe en el tipo; si no, tampoco. Es la clase de garantía barata —una sola línea— que elimina de raíz toda una familia de bugs de mantenimiento.
Diseñar bien esa superficie es la parte que merece cuidado. Una buena API de store se parece a la de un objeto bien pensado: lecturas explícitas, un conjunto cerrado de operaciones con nombre, cero fugas de la maquinaria interna.
Estado privado
Signals, stores y el setter viven en el closure de la factory y no se exportan jamás. La única puerta a la mutación son las acciones.
Getters de lectura
Expón el estado como getters que llaman al signal al leerse; preservan la reactividad de grano fino y son inmutables desde fuera.
Acciones con nombre
Cada transición válida es un verbo. La lista de acciones es el catálogo cerrado de todo lo que puede pasarle al estado.
dispose opcional
Si la factory usa createRoot, devuelve su dispose para que quien la instancie pueda apagar la raíz cuando toque.
Definir no es instanciar
El giro conceptual del patrón está en el nombre: createContadorStore es una fábrica, no una instancia. Definir el store —escribir la factory— y crearlo —llamarla— son dos actos distintos, y esa separación es lo que le da al patrón su alcance. El mismo archivo que define la factory no decide cuántas veces se llama ni quién la posee; esa decisión la toma el consumidor.
De ahí salen dos modos de uso, y elegir bien entre ellos es medio nivel resumido en una decisión. El primero: exportar una única instancia creada al importar, y tienes de vuelta el singleton de módulo, ahora encapsulado y tipado.
// Singleton: una instancia para toda la app cliente.
export const contador = createContadorStore();
El segundo: exportar la factory misma y dejar que cada consumidor cree su instancia, típicamente para proveerla por contexto a un subárbol. Cada llamada produce un store independiente, con su propio estado y su propio dispose.
// Una instancia por subarbol, provista por contexto.
function ProveedorContador(props: { children: any }) {
const store = createContadorStore();
onCleanup(store.dispose); // muere con el proveedor
return (
<ContadorCtx.Provider value={store}>
{props.children}
</ContadorCtx.Provider>
);
}
flowchart TD F[createContadorStore factory] --> D[definicion: estado y logica dentro] D --> R[ReturnType deriva el tipo de la API] F --> S[una llamada: singleton exportado] F --> M[muchas llamadas: instancia por contexto] style F fill:#a6e3a1,color:#11111b style R fill:#89b4fa,color:#11111b style M fill:#f9e2af,color:#11111b
La misma factory sirve a ambos mundos sin cambiar una línea de su cuerpo. Esa neutralidad respecto a la instanciación es lo que la hace tan útil, y lo que la conecta con las dos lecciones que cierran el nivel: cuándo un singleton basta y cuándo el estado quiere vivir por subárbol o por petición.
Hay un tercer beneficio que la práctica valora tanto como la encapsulación y el tipado: la testabilidad. Una factory es una función que fabrica un store fresco cada vez que la llamas, así que cada test puede crear su propia instancia aislada, ejercitar sus acciones y disponerla al terminar, sin variables de módulo que arrastren estado de un caso al siguiente. El singleton importado obliga a resetear estado global entre tests; la factory elimina esa fricción porque no hubo estado global que resetear hasta que tú decidiste crearlo. Definir sin instanciar es, también, definir sin contaminar.
Un signal suelto en un módulo mezcla tres decisiones en un solo gesto: qué es el estado, qué lógica lo gobierna y cuántas veces existe. Esa fusión pasa inadvertida mientras solo quieres un singleton en el cliente, y se vuelve una jaula en cuanto necesitas algo más —una segunda instancia para un test, un store por petición en el servidor, un subárbol con su propia copia—. El patrón factory desmonta esa fusión al convertir un store en un valor de primera clase: no una entidad que existe porque un módulo se cargó, sino algo que una función produce cuando se la llama, tantas veces como haga falta y ninguna hasta entonces. Esta cosificación es la misma idea que en el resto de la ingeniería separa la clase del objeto, el plano del edificio, la receta del plato: cuando la definición deja de ser la instancia, ganas la libertad de instanciar cero, una o mil veces sin tocar la definición. En Solid la ganancia es especialmente aguda porque el mismo store puede necesitar exactamente esas tres cardinalidades en distintos contextos: cero veces hasta que alguien lo pide, una vez como singleton de cliente, muchas veces como estado por petición bajo SSR. La factory es el único diseño que las sirve a las tres sin duplicar código, y por eso es la forma canónica del estado global en Solid cuando el estado tiene algo de lógica. Lo demás —los getters, el tipo derivado, el dispose— son consecuencias ordenadas de haber tomado la decisión de fondo: definir es escribir la receta; instanciar es cocinar; y confundirlos era el error que la factory corrige.
- Escribe
createContadorStore()que encapsule un signal y un memo dentro decreateRoot, y devuelva getters de solo lectura, acciones con nombre ydispose. - Deriva
type ContadorStore = ReturnType<typeof createContadorStore>y úsalo para tipar uncreateContext; comprueba que añadir una acción actualiza el tipo sin tocar nada más. - Exporta una única instancia como singleton y consúmela desde dos componentes; confirma que comparten estado.
- Cambia a exportar la factory y crea una instancia por subárbol con un proveedor que llame a
onCleanup(store.dispose); verifica que dos subárboles tienen contadores independientes. - Demuestra con un ejemplo que desestructurar un getter (
const { cuenta } = store) rompe la reactividad y explica por qué leerstore.cuentaen el punto de uso no.