Arquitectar una app real de principio a fin
Pasar de dominar los primitivos a diseñar una aplicación completa: cómo decidir entre signal, store y context según la naturaleza del estado; por qué el servidor es la fuente de verdad y los datos se modelan con query, createAsync y actions en lugar de estado de cliente; cómo se estructuran rutas, layouts y auth en SolidStart; y qué forma toma un proyecto serio. Una guía de decisiones de arquitectura reales, no una lista de APIs, con la mentalidad reactiva escalada de la UI al backend.
La decisión de estado: signal, store o context
La primera pregunta de toda arquitectura Solid no es «qué librería de estado» —no hace falta ninguna— sino «qué forma tiene este estado y hasta dónde llega». Cuatro preguntas encadenadas resuelven casi todos los casos. ¿Pertenece al servidor? Entonces no es estado de cliente: es un dato, y se modela con recursos, no con signals. ¿Es un valor escalar y efímero —un booleano de menú abierto, un texto de input—? Un createSignal y basta. ¿Es un árbol de datos anidado que se lee y muta por partes —una lista de tareas, un formulario complejo—? Un createStore, para tener reactividad fina en cada hoja sin fabricar cien signals. Y sobre cualquiera de ellos: ¿lo necesitan ramas lejanas del árbol? Entonces envuélvelo en un context.
Traducido a código, las tres respuestas de cliente se ven así, y la diferencia entre ellas no es de sintaxis sino de forma del estado.
// escalar y efimero -> signalconst [menuAbierto, setMenuAbierto] = createSignal(false);// arbol anidado que se muta por partes -> store (reactividad fina por hoja)const [carrito, setCarrito] = createStore({ items: [], total: 0 });setCarrito("items", (i) => [...i, nuevo]); // solo re-notifica lo que toca// necesario en ramas lejanas -> context que envuelve un signal o un storeconst TemaCtx = createContext<Accessor<string>>();const usarTema = () => useContext(TemaCtx)!; // sin prop drilling, reactividad intactaLa trampa clásica es meter estado de servidor en un store de cliente «para tenerlo a mano»: lo copias, y ahora tienes dos fuentes de verdad que se desincronizan. La regla que ordena todo: el estado de cliente es solo lo que el servidor no sabe —qué acordeón está abierto, qué campo tiene foco, qué paso del wizard ves—. Todo lo demás son datos, y los datos se cargan, no se guardan.
Datos: el servidor es la fuente de verdad
En una app real, la mayor parte de lo que parece «estado» son datos remotos, y ahí Solid tiene un modelo de primera clase. Las lecturas se modelan con createAsync sobre una función query, que deduplica llamadas idénticas y cachea dentro del alcance de la petición; el router puede hacer preload de esa misma query al pasar el ratón sobre un enlace, así el dato ya está cuando navegas. Las escrituras se modelan con action y funciones "use server", que corren solo en el servidor, y tras completarse revalidan las queries afectadas para que la UI se refresque sin que orquestes nada.
// datos: leer con query + createAsync, escribir con actionimport { query, action, createAsync, useAction } from "@solidjs/router";const getTareas = query(async () => { "use server"; return db.tareas.findMany(); // corre solo en el servidor}, "tareas"); // clave de cache y dedupconst crearTarea = action(async (texto: string) => { "use server"; await db.tareas.create({ texto }); return revalidate(getTareas.key); // refresca la query tras mutar});function Tareas() { const tareas = createAsync(() => getTareas()); // lectura reactiva y suspendible const agregar = useAction(crearTarea); return ( <Suspense fallback={<Spinner />}> <For each={tareas()}>{(t) => <li>{t.texto}</li>}</For> </Suspense> );}El patrón mental es datos hacia abajo, acciones hacia arriba: los componentes leen resources y renderizan; cuando el usuario actúa, disparan una action que muta en el servidor y revalida. Suspense dibuja el esqueleto de carga y ErrorBoundary el de fallo, de modo que los tres estados —cargando, error, dato— quedan cubiertos por estructura, no por banderas manuales de isLoading.
Rutas, layouts y auth
El enrutado por archivos de Solid Router mapea src/routes a la jerarquía de URLs, con layouts anidados que envuelven a sus hijos. La sesión y la autenticación no son un problema de UI: son un problema de la frontera servidor. La protección de verdad vive en el middleware y en cada función "use server", que valida la sesión antes de tocar datos; ocultar un botón en el cliente es cosmética, no seguridad. Un atacante llama a tu server function directamente, así que ahí es donde se comprueba quién pide.
// la seguridad vive en el servidor, no en el ocultado de UIconst getPanel = query(async () => { "use server"; const usuario = await requireUser(); // lanza o redirige si no hay sesion return db.panel.forUser(usuario.id);}, "panel");// src/app.tsx: la sesion nace en la raiz, una instancia por peticionexport default function App() { return ( <Router root={(props) => ( <SesionProvider> <Suspense>{props.children}</Suspense> </SesionProvider> )}> <FileRoutes /> </Router> );}Fíjate en dónde vive la sesión: en un proveedor en la raíz del árbol, no en un módulo. Como el componente raíz corre una vez por petición en SSR, cada solicitud obtiene su propia instancia de sesión y no hay fuga de un usuario a otro —la lección de estado global y SSR, aplicada al esqueleto de la app—. El estado de UI por usuario vive en el árbol; los datos viven en el servidor; y la frontera entre ambos, la server function, es donde se comprueba la identidad.
La estructura de un proyecto serio
Con esas decisiones tomadas, la estructura casi se escribe sola. Cada carpeta encarna una de las decisiones anteriores, de modo que el árbol de archivos se lee como el mapa del grafo.
src/ routes/ # paginas y layouts: file routing, cada ruta un nodo de UI lib/ # SOLO servidor: acceso a datos, query, actions, requireUser components/ # UI reutilizable sin estado de dominio state/ # contextos y stores compartidos (sesion, tema, carrito) app.tsx # raiz: compone Router, proveedores y limites por peticionLa clave no es la carpeta exacta sino la regla de dependencia: la UI depende del estado y de los datos, nunca al revés, y la frontera servidor —todo lo que vive en lib y en las funciones "use server"— es explícita y estrecha. Si un componente importa algo de lib que no pasa por esa frontera, has abierto un agujero; si lib importa un componente, has invertido la dependencia. Mantener esas flechas en su sitio es, a escala de carpetas, la misma disciplina que mantener un signal con un solo setter.