Pantalla real: listado y detalle maestro-detalle
Integrar en una sola pantalla todo el nivel: dos rutas anidadas —lista y detalle— donde `query` da identidad a los datos, `preload` los calienta en hover, `createAsync` los lee, `Suspense` marca las fronteras de carga y las transiciones del router mantienen visible el detalle anterior mientras el nuevo llega. Cómo `useIsRouting` pinta un indicador de navegación sin parpadeos, por qué bajo transición un `Suspense` ya resuelto no vuelve al fallback, y cómo `useSubmission` cierra el bucle con una mutación optimista que revalida por clave.
Cada pieza del nivel se ha presentado sola; ahora se montan juntas en la pantalla que las justifica a todas: un maestro-detalle. A la izquierda, una lista de productos; a la derecha, el detalle del seleccionado. Es el patrón más común de una app de datos y el banco de pruebas perfecto, porque exige que query, preload, createAsync, Suspense y las transiciones cooperen sin fricción. Cuando funciona bien, navegar entre productos se siente instantáneo y sin parpadeos: el detalle anterior permanece mientras el nuevo carga, la precarga en hover adelanta la red, y una reseña recién publicada aparece al momento. Esta lección cablea esa experiencia de principio a fin.
- Montar dos rutas anidadas —lista y detalle— con
query,preloadycreateAsync. - Mantener visible el detalle anterior mientras el nuevo carga, aprovechando las transiciones del router.
- Pintar un indicador de navegación con
useIsRoutingsin queSuspenseparpadee. - Cerrar el bucle con una mutación optimista que revalida por clave con
useSubmission.
Anatomía de la pantalla: dos rutas anidadas
El maestro-detalle se modela como una ruta de layout que pinta la lista y aloja un hueco para el detalle, más una ruta hija que rellena ese hueco según el id de la URL. El layout lee la lista una vez; cada hijo lee su producto. Ambas rutas declaran preload para calentar la caché antes de renderizar.
// ~/routes/productos.tsx (layout: lista + hueco de detalle)
import type { RouteDefinition } from "@solidjs/router";
import { createAsync, useIsRouting, A } from "@solidjs/router";
import { Suspense, For } from "solid-js";
import { getProductos } from "~/lib/productos";
export const route = {
preload: () => void getProductos(),
} satisfies RouteDefinition;
export default function ProductosLayout(props) {
const productos = createAsync(() => getProductos());
const navegando = useIsRouting();
return (
<div class="maestro-detalle" classList={{ navegando: navegando() }}>
<aside>
<Suspense fallback={<EsqueletoLista />}>
<For each={productos()}>
{(p) => <A href={`/productos/${p.id}`}>{p.nombre}</A>}
</For>
</Suspense>
</aside>
<main>{props.children}</main>
</div>
);
}
Cablear el detalle: query, preload y createAsync
La ruta hija lee su producto por el id de la URL. preload dispara la query —y de paso las reseñas diferibles— sin esperarlas; createAsync las relee y encuentra la caché caliente. Los enlaces A de la lista, al hacer hover, ya habrán ejecutado este preload, así que al hacer clic el dato suele estar listo.
// ~/routes/productos/[id].tsx (detalle)
import type { RouteDefinition } from "@solidjs/router";
import { createAsync, useParams } from "@solidjs/router";
import { Suspense } from "solid-js";
import { getProducto, getResenas } from "~/lib/productos";
export const route = {
preload({ params }) {
void getProducto(params.id); // critico
void getResenas(params.id); // diferido: streameara
},
} satisfies RouteDefinition;
export default function Detalle() {
const params = useParams();
const producto = createAsync(() => getProducto(params.id), { deferStream: true });
const resenas = createAsync(() => getResenas(params.id));
return (
<Suspense fallback={<EsqueletoDetalle />}>
<h1>{producto()?.nombre}</h1>
<p>{producto()?.precio}</p>
<Suspense fallback={<EsqueletoResenas />}>
<ListaResenas resenas={resenas()} />
</Suspense>
</Suspense>
);
}
Suspense y transiciones: navegar sin parpadeos
Aquí ocurre la magia que hace que la pantalla se sienta fluida. Las navegaciones del router son transiciones, y eso tiene dos consecuencias que colaboran. Primera: createAsync conserva el valor anterior mientras el nuevo carga, en vez de volver a undefined. Segunda: bajo una transición, un Suspense que ya resolvió no vuelve a su fallback —mantiene el contenido viejo hasta que el nuevo está listo y entonces lo sustituye—. El resultado es que al saltar del producto 7 al 8, el detalle del 7 permanece nítido mientras el 8 llega, sin un parpadeo al esqueleto.
El fallback del Suspense, por tanto, solo se ve en la primera carga, cuando aún no había nada que conservar. Para señalar las navegaciones siguientes se usa useIsRouting, que devuelve un accessor booleano de si hay una transición en curso; con él pintas una barra de progreso o atenúas el panel, sin tocar el contenido.
const navegando = useIsRouting();
// clase que atenua el detalle mientras se navega, sin quitar el contenido viejo
<main classList={{ cargando: navegando() }}>{props.children}</main>
flowchart LR
H[hover sobre A producto 8] -->|preload| Q8[getProducto 8 en vuelo]
Q8 --> K[cache clave 8]
C[clic navega a producto 8] -->|transicion| T{Suspense ya resuelto}
T -->|mantiene el 7 visible| V[detalle 7 nitido]
K -->|dato 8 listo| SW[sustituye 7 por 8 sin parpadeo]
V --> SW
IR[useIsRouting] -->|barra de progreso| UI[senal de navegacion]
style Q8 fill:#f9e2af,color:#11111b
style K fill:#a6e3a1,color:#11111b
style SW fill:#89b4fa,color:#11111bCerrar el bucle: mutar y revalidar en la misma pantalla
Una pantalla real no solo lee: escribe. Publicar una reseña es una action que, al terminar, revalida solo las reseñas de ese producto con keyFor. Mientras la petición vuela, useSubmission expone su estado pendiente y sus datos enviados, con los que pintas la reseña optimista —proyectas el futuro antes de que el servidor confirme—; cuando la revalidación trae la verdad, la fila fantasma cede su sitio a la real.
import { action, json, useSubmission } from "@solidjs/router";
import { getResenas } from "~/lib/productos";
export const publicarResena = action(async (form: FormData) => {
"use server";
const id = form.get("productoId") as string;
await db.resena.create({ data: leer(form) });
return json({ ok: true }, { revalidate: getResenas.keyFor(id) }); // solo estas resenas
}, "publicarResena");
function FormResena(props: { productoId: string }) {
const envio = useSubmission(publicarResena);
return (
<form action={publicarResena} method="post">
<input type="hidden" name="productoId" value={props.productoId} />
<textarea name="texto" />
<button disabled={envio.pending}>
{envio.pending ? "publicando..." : "publicar"}
</button>
</form>
);
}
Con esto la pantalla respira sola: la lista se calienta en hover, el detalle cambia sin parpadeos, las reseñas diferidas se transmiten por streaming, y una nueva reseña aparece al instante y se reconcilia con el servidor bajo su clave. Cada primitiva del nivel ha aportado su parte, y ninguna sobra.
Layout mas hijo
La lista vive en el layout y lee getProductos una vez, el detalle vive en la ruta hija y lee getProducto por el id de la URL.
Transicion sin parpadeo
Navegar es una transicion: createAsync guarda el valor previo y el Suspense resuelto no vuelve al fallback.
Escribir y revalidar
useSubmission pinta lo optimista mientras la action revalida solo la clave tocada con keyFor.
Dónde pones cada Suspense decide qué se mantiene y qué parpadea al cambiar de detalle. Si envuelves todo el detalle en un solo Suspense, la transición conserva la ficha entera mientras carga la nueva. Si además anidas un Suspense interno solo para las reseñas, esa región puede recargar de forma independiente sin arrastrar al resto. Piensa las fronteras como las junturas por donde la pantalla se articula: cada una es a la vez una unidad de streaming en SSR y una unidad de conservación bajo transición en el cliente. No son adorno; son el esqueleto temporal de la pantalla.
Al integrar el nivel entero en una pantalla, emerge una tesis que ninguna lección aislada dejaba ver: la sensación de fluidez no nace de minimizar el tiempo de carga —siempre habrá red—, sino de eliminar los retrocesos a estados vacíos. Cada parpadeo al esqueleto, cada salto a undefined, cada fallback que reaparece donde ya había contenido, es un retroceso que el ojo percibe como brusquedad, aunque los milisegundos totales sean pocos. Todo lo que has aprendido conspira precisamente contra esos retrocesos. query da al dato una identidad que sobrevive a la navegación, para que volver a él no sea repedirlo. preload adelanta la red al hover, para que el clic no espere. createAsync bajo transición conserva el valor anterior, para que el hueco nunca quede vacío. Suspense bajo transición mantiene el contenido resuelto, para que el fallback no reaparezca. La revalidación por clave recomputa solo lo que cambió, para que lo demás no parpadee. Y el streaming entrega lo crítico primero, para que lo esencial nunca dependa de lo accesorio. Míralo en conjunto y verás que las cinco piezas no son cinco técnicas sueltas sino una sola disciplina con cinco caras: preservar la continuidad visual del dato a través del tiempo y del espacio de la app. Ese es el verdadero tema del nivel. No aprendiste a cargar datos rápido; aprendiste a que el usuario nunca vea la costura entre el dato que tenía y el que viene. Cuando esa continuidad se te vuelve el criterio con el que juzgas cada pantalla, dejas de construir vistas que cargan y empiezas a construir superficies que fluyen.
- Monta la ruta de layout con la lista leída por
createAsyncy los enlacesA, y la ruta hija de detalle que leegetProducto(params.id). - Añade
preloada ambas rutas y confirma en red que el hover sobre un producto dispara su petición antes del clic. - Navega entre dos productos y verifica que el detalle anterior permanece visible sin parpadear al esqueleto mientras el nuevo carga.
- Pinta una barra de progreso con
useIsRoutingy comprueba que aparece durante la transición pero el contenido viejo no desaparece. - Publica una reseña con una
action, píntala optimista conuseSubmissiony revalida congetResenas.keyFor(id); observa cómo la fila fantasma se reconcilia con la real sin remontar la lista.