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.
Saber qué hace createStore no te dice cuándo usarlo. La distancia entre conocer los primitivos y arquitectar una app se mide en decisiones: dónde vive cada pieza de estado, quién es la fuente de verdad de los datos, cómo se protegen las rutas y dónde nace la sesión. Esta lección recorre esas decisiones de principio a fin con la mentalidad que Solid impone —el estado es un grafo, y arquitectar es diseñar ese grafo a escala de aplicación—. No hay recetas mágicas: hay criterios, y cada uno se deriva de algo que ya sabes.
- Elegir entre signal, store y context según la naturaleza y el alcance del estado.
- Tratar el servidor como fuente de verdad y modelar los datos con resources,
queryy actions. - Estructurar rutas, layouts y auth en SolidStart con aislamiento correcto por petición.
- Diseñar la estructura de un proyecto serio donde cada capa tenga una responsabilidad clara.
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.
flowchart TD Q1[pertenece al servidor] -->|si| SRV[es un dato no estado cliente usa resource createAsync] Q1 -->|no| Q2[valor escalar y efimero] Q2 -->|si| SIG[createSignal] Q2 -->|no| Q3[arbol de datos anidado] Q3 -->|si| ST[createStore] Q3 -->|no| SIG SIG --> Q4[lo necesitan ramas lejanas] ST --> Q4 Q4 -->|si| CTX[envuelvelo en un context provider] Q4 -->|no| LOC[dejalo local al componente] style SRV fill:#f9e2af,color:#11111b style ST fill:#89b4fa,color:#11111b style CTX fill:#a6e3a1,color:#11111b
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 -> signal
const [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 store
const TemaCtx = createContext<Accessor<string>>();
const usarTema = () => useContext(TemaCtx)!; // sin prop drilling, reactividad intacta
La 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 action
import { 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 dedup
const 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.
La tentación de quien viene de React es traer una librería de fetching. En SolidStart no hace falta: query te da dedup y cache por clave, createAsync la integra con Suspense y las transiciones, el router hace preload en la navegación, y revalidate invalida tras las actions. Ese cuarteto cubre lo que en otro ecosistema pedías a una dependencia externa. Añadir una encima duplica el cache y te obliga a sincronizar dos fuentes de verdad. Empieza siempre con las primitivas del framework y solo sal de ahí cuando choques con un límite real, que llega mucho más tarde de lo que crees.
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 UI
const 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 peticion
export 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 peticion
La 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.
Estado de cliente
Solo lo que el servidor no sabe: menús, foco, pasos de wizard. Signal si es escalar, store si es un árbol, context si cruza el árbol.
Datos de servidor
La fuente de verdad. query + createAsync para leer con dedup y cache; action + "use server" para mutar y revalidar.
Frontera y auth
La seguridad vive en el middleware y en cada server function. La sesión nace en la raíz del árbol, una instancia por petición.
La app entera es el mismo grafo reactivo que viste en un contador, solo que estirado hasta abarcar el backend, y arquitectarla bien es, literalmente, decidir la forma de ese grafo. Cada decisión de las que has tomado es una decisión sobre nodos y aristas: un createSignal es un source pequeño para un valor que solo tú posees; un createStore es un subgrafo de sources finos para un árbol de datos que quieres mutar por partes sin invalidar el todo; un context es una arista larga que lleva un source a ramas lejanas sin arrastrarlo por props; y un resource es un source cuyo valor lo gobierna el servidor y que se resuelve de forma asíncrona bajo Suspense. La pregunta que ordena la arquitectura —“¿este estado pertenece a un usuario, a una petición, o al proceso?”— es la misma pregunta de alcance que resuelve el ownership, ahora planteada sobre la vida de un proceso servidor en vez de sobre la vida de un componente. Por eso el patrón cardinal es tratar el servidor como la única fuente de verdad y modelar los datos como lecturas suspendibles y escrituras que revalidan, en lugar de copiarlos a un store de cliente que inevitablemente se desincroniza: mantienes el grafo con una sola raíz de verdad por cada dato, igual que mantienes un signal con un solo setter. Y por eso la seguridad no se pone en el ocultado de un botón sino en la frontera de la server function, porque esa frontera es el único punto donde el grafo del cliente y el del servidor se tocan, y todo límite —de error, de suspense, de auth, de petición— se coloca exactamente donde el grafo cambia de dueño. Cuando ves la aplicación así, la elección entre signal, store, context y resource deja de ser un catálogo que consultar y se vuelve una lectura directa de la naturaleza del dato: quién lo posee, cuánto vive, hasta dónde llega y quién manda sobre su verdad. Eso es arquitectura reactiva, y es la misma idea de siempre a una escala mayor.
- Clasifica cinco piezas de estado —input de nueva tarea, lista de tareas, usuario autenticado, tema claro/oscuro, filtro activo— en signal, store, context o resource, y justifica cada una.
- Modela la lectura de tareas con
query+createAsyncy su creación con unaactionque revalide; explica por qué no las copias a un store de cliente. - Coloca
SuspenseyErrorBoundarydonde correspondan y argumenta por qué cubren los tres estados sin una sola banderaisLoadingmanual. - Protege la ruta del panel comprobando la sesión dentro de la server function, y explica por qué ocultar el enlace en el cliente no es seguridad.
- Sitúa el
SesionProvideren la raíz deapp.tsxy razona, con dos peticiones concurrentes en SSR, por qué cada una tiene su propia sesión.