Cómo crear un panel de widgets personalizable con TanStack Query
La mayoría de los paneles de productos de software se construyen del mismo modo: un componente de nivel superior lanza diez llamadas a la API, espera a que todas terminen y renderiza todo a la vez. En una demostración parece funcionar bien. En producción genera problemas que se agravan con el tiempo. Un solo endpoint lento bloquea toda la vista. Un error transitorio en un widget afecta a toda la pantalla. Las actualizaciones en tiempo real requieren coordinar nuevas consultas, algo que acaba siendo imposible de mantener. Añadir un widget obliga a razonar sobre un estado global que nunca se diseñó para admitirlo.
TanStack Query (antes React Query) lo resuelve tratando la obtención de datos como una responsabilidad fundamental, no como un añadido al estado del componente. Cada consulta tiene su propia clave de caché, ciclo de vida y estado de error. En un panel de widgets, esto se traduce directamente en que cada widget gestiona sus datos y sus propios estados de carga, error y éxito. El resto de la aplicación no necesita conocerlos ni ocuparse de ellos.
Separar la disposición de los datos
Antes de escribir la lógica de obtención de datos, la arquitectura requiere separar claramente dos responsabilidades: qué datos obtiene un widget y dónde se sitúa. Ambas pueden cambiar de forma independiente: un usuario puede mover un widget sin cambiar los datos que muestra, y el backend puede devolver datos nuevos sin afectar a la disposición. Mezclarlas genera problemas de coordinación más adelante.
interface WidgetConfig {
id: string;
type: 'revenue' | 'orders' | 'users' | 'activity-feed';
title: string;
queryKey: string[];
refetchInterval?: number;
}
interface WidgetLayout {
i: string; // matches WidgetConfig.id
x: number;
y: number;
w: number;
h: number;
}
El campo queryKey de WidgetConfig es el vínculo con la caché de TanStack Query. Dos widgets con la misma queryKey comparten un resultado almacenado en caché: una solicitud de red, dos consumidores. Dos widgets con claves distintas son totalmente independientes. Esta distinción importa al construir paneles compuestos en los que varias vistas muestran los mismos datos subyacentes.
Obtención de datos por widget
Cada componente de widget llama a useQuery con su propia configuración. El parámetro staleTime indica a TanStack Query cuánto tiempo debe considerar actualizados los datos en caché antes de marcarlos como desactualizados. Configurarlo correctamente para cada tipo de widget evita consultas innecesarias y mantiene los datos razonablemente actualizados.
function RevenueWidget({ config }: { config: WidgetConfig }) {
const { data, isLoading, isError } = useQuery({
queryKey: config.queryKey,
queryFn: () => fetchRevenue(),
staleTime: 60_000,
refetchInterval: config.refetchInterval,
});
if (isLoading) return <WidgetSkeleton />;
if (isError) return <WidgetError />;
return (
<div className="widget-content">
<p className="text-3xl font-bold">{formatCurrency(data.total)}</p>
<p className="text-sm text-muted">vs last period: {data.changePercent}%</p>
</div>
);
}
Las vistas de carga con bloques de relleno y los estados de error se limitan deliberadamente a cada widget. No hay un indicador de carga global ni un gestor de errores de toda la aplicación que capture los fallos de los widgets. Cada widget presenta su propio estado al usuario. Por tanto, que un widget de ingresos muestre una vista de carga junto a otro de pedidos con datos en tiempo real es el comportamiento correcto y esperado, no un indicio de fallo.
Límites de error por widget
El estado isError de useQuery gestiona los errores de obtención de datos, pero los errores de renderizado —que se producen durante la ejecución del componente— requieren el mecanismo de límites de error de React. Envolver cada widget en su propio límite evita que un error durante su renderizado bloquee todo el panel. El usuario puede seguir trabajando con los demás widgets mientras el que falla muestra un estado de error recuperable.
class WidgetErrorBoundary extends React.Component<
{ children: React.ReactNode; widgetId: string },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="widget-error">
<p>This widget encountered an error.</p>
<button onClick={() => this.setState({ hasError: false })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
function Widget({ config }: { config: WidgetConfig }) {
return (
<WidgetErrorBoundary widgetId={config.id}>
<WidgetRouter config={config} />
</WidgetErrorBoundary>
);
}
El botón de reintento restablece el estado del límite de error y permite al usuario recuperarse sin recargar toda la página. En la práctica, la mayoría de los errores de renderizado de componentes de widgets se deben a estructuras de datos con valores nulos inesperados devueltas por una API; un reintento que active una nueva consulta suele resolverlos. Tenga en cuenta que restablecer el límite de error solo vuelve a renderizar el widget; no vuelve a obtener los datos por sí solo y, si los datos de la caché siguen vigentes, el widget se renderiza de nuevo con los mismos datos. Para reintentar con una nueva consulta, restablezca también la consulta del widget o vuelva a obtener sus datos en el manejador del reintento (por ejemplo, con queryClient.resetQueries({ queryKey: config.queryKey })), o utilice QueryErrorResetBoundary de TanStack Query cuando los errores de consulta se lancen al límite de error.
Arrastrar y cambiar el tamaño
La mecánica de arrastrar y cambiar el tamaño es, en última instancia, secundaria frente a la capa de datos. react-grid-layout gestiona bien el posicionamiento: recibe un arreglo de disposición, renderiza los elementos secundarios en las celdas de una cuadrícula y llama a onLayoutChange cada vez que el usuario mueve un widget o cambia su tamaño. La implementación es sencilla.
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
function Dashboard({ widgets, layouts, onLayoutChange }: DashboardProps) {
return (
<GridLayout
layout={layouts}
cols={12}
rowHeight={60}
width={1200}
onLayoutChange={onLayoutChange}
draggableHandle=".widget-drag-handle"
>
{widgets.map(config => (
<div key={config.id}>
<div className="widget-drag-handle cursor-grab">
<span>{config.title}</span>
</div>
<Widget config={config} />
</div>
))}
</GridLayout>
);
}
La propiedad draggableHandle merece atención. Sin ella, hacer clic en cualquier parte de un widget inicia un arrastre, lo que entra en conflicto con elementos interactivos como gráficos, botones y campos de entrada dentro de los widgets. Restringir el arrastre a un área designada —una barra de título o un icono de agarre— resuelve ese conflicto de interacción de forma sencilla.
Persistir el estado de la disposición
La persistencia de la disposición tiene dos capas: la persistencia inmediata, para que una recarga de la página no descarte la organización, y la persistencia en el servidor, para que la disposición acompañe al usuario entre dispositivos. Requieren implementaciones distintas y deben combinarse de forma deliberada para que la copia local y la del servidor no se sobrescriban entre sí.
Para la persistencia inmediata, localStorage funciona bien y no requiere una solicitud de red y su respuesta:
function useDashboardLayout(initialLayouts: WidgetLayout[]) {
const storageKey = 'dashboard-layout';
const [layouts, setLayouts] = useState<WidgetLayout[]>(() => {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : initialLayouts;
} catch {
return initialLayouts;
}
});
const handleLayoutChange = (newLayouts: WidgetLayout[]) => {
setLayouts(newLayouts);
localStorage.setItem(storageKey, JSON.stringify(newLayouts));
};
return { layouts, handleLayoutChange };
}
Para la persistencia entre dispositivos, tratar la disposición como estado del servidor —obtenido con useQuery y actualizado con useMutation — encaja de forma natural en la configuración existente de TanStack Query. Retrasar el guardado hasta que cesen los cambios evita una solicitud de red por cada píxel desplazado durante el arrastre. El hook siguiente solo guarda; para que los layouts devueltos reflejen la disposición que el usuario acaba de organizar, escriba también la nueva disposición en la caché (por ejemplo, con queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) o invalide la consulta una vez que el guardado se complete correctamente.
function usePersistedLayout(userId: string, initialLayouts: WidgetLayout[]) {
const { data: layouts } = useQuery({
queryKey: ['dashboard-layout', userId],
queryFn: () => fetchUserLayout(userId),
initialData: initialLayouts,
});
const { mutate: saveLayout } = useMutation({
mutationFn: (newLayouts: WidgetLayout[]) =>
saveUserLayout(userId, newLayouts),
});
const debouncedSave = useMemo(
() => debounce(saveLayout, 1000),
[saveLayout]
);
return { layouts, handleLayoutChange: debouncedSave };
}
Actualizaciones en tiempo real
La opción refetchInterval permite que cada widget controle de forma independiente la frecuencia con la que consulta si hay datos nuevos. Un registro de actividad puede necesitar actualizaciones cada diez segundos. Un resumen mensual de ingresos puede funcionar bien actualizándose una vez por hora. La consulta periódica global, la alternativa habitual, obliga a todos los widgets a usar el mismo intervalo. Esto suele provocar un exceso de consultas para satisfacer al widget más exigente, o consultas insuficientes que dejan desactualizados los widgets sensibles al tiempo.
const WIDGET_REFRESH_INTERVALS: Record<WidgetConfig['type'], number | undefined> = {
'activity-feed': 10_000,
'orders': 30_000,
'revenue': 3_600_000,
'users': 300_000,
};
const config: WidgetConfig = {
id: 'orders-widget',
type: 'orders',
title: 'Recent Orders',
queryKey: ['orders'],
refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};
Sin embargo, la consulta periódica indiscriminada genera su propio problema: un panel con muchos widgets que consultan a intervalos cortos produce un tráfico de red considerable, incluso cuando la pestaña del navegador está en segundo plano. TanStack Query ofrece una solución directa mediante refetchIntervalInBackground: false, que pausa las consultas periódicas de las pestañas ocultas. Combinado con refetchOnWindowFocus: true (activado por defecto), cubre la mayoría de los casos: los datos se mantienen actualizados mientras el usuario trabaja activamente y las solicitudes de red se pausan cuando deja de hacerlo.
El enrutador de widgets y la configuración global
Un componente central que relaciona los tipos de widgets con sus implementaciones mantiene la lógica de renderizado en un único lugar. Añadir un tipo de widget requiere escribir el componente y añadir un caso; no cambia nada más.
function WidgetRouter({ config }: { config: WidgetConfig }) {
switch (config.type) {
case 'revenue': return <RevenueWidget config={config} />;
case 'orders': return <OrdersWidget config={config} />;
case 'users': return <UsersWidget config={config} />;
case 'activity-feed': return <ActivityFeedWidget config={config} />;
default: return <div>Unknown widget type</div>;
}
}
La configuración global de QueryClient establece valores predeterminados que cada widget puede modificar. En un panel, un valor predeterminado de 30 segundos para staleTime evita consultas constantes en segundo plano y mantiene los datos razonablemente actualizados cuando el usuario sale de la vista y vuelve.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
Qué problemas evita esta arquitectura
Los problemas que evita esta arquitectura son tan importantes como las capacidades que ofrece. Los estados de carga globales dejan de ser necesarios: no hay un momento en que se carguen «todos los datos», solo widgets individuales. Los fallos en cascada se vuelven mucho menos probables: un error de renderizado de un widget no puede provocar que los demás dejen de funcionar porque cada límite de error está acotado a un único widget, y los widgets con claves de consulta distintas no comparten el estado de obtención de datos. Los widgets que comparten deliberadamente una clave de consulta también comparten sus datos y su estado de error, por lo que un fallo en esa consulta afecta a todos los widgets que utilizan esa clave. Añadir widgets no requiere modificar el estado compartido ni coordinar sus necesidades de datos con las de los existentes.
El análisis de rendimiento también se vuelve manejable. La actividad de red se atribuye claramente a widgets individuales mediante sus claves de consulta, que aparecen en TanStack Query DevTools. Detectar que un widget hace demasiadas solicitudes o que una consulta nunca se utiliza lleva segundos, sin necesidad de analizar una cascada de solicitudes de red.
La mecánica de arrastrar y cambiar el tamaño —la parte visible e interactiva que da al panel un acabado cuidado— es secundaria frente a esta base para la gestión de datos. Cualquier biblioteca de cuadrícula competente gestiona el posicionamiento. Lo que determina si un panel de widgets funciona bien en producción es que la capa de datos esté diseñada para gestionar los fallos de forma controlada y actualizarse de manera independiente. Esta arquitectura facilita ese diseño de forma natural.
Para los equipos que construyen portales de clientes o herramientas internas de analítica, el patrón se puede aplicar sin modificaciones. La abstracción de widgets no necesita una interfaz compleja de arrastrar y soltar para aportar valor: incluso una cuadrícula sencilla y configurable de tarjetas de datos se beneficia del aislamiento de consultas y de los límites de error por widget. La disposición en cuadrícula es una mejora; la arquitectura de datos es la base.
Artículos relacionados
- Despliegue de aplicaciones React en producción: configuración completa de Docker con el proxy inverso Traefik — una vez construido el panel, el despliegue en contenedores con Traefik gestiona HTTPS y el enrutamiento sin administrar certificados manualmente.
- Optimización del rendimiento de WooCommerce: de 8 segundos a 0,2 segundos de tiempo de carga — las decisiones sobre la arquitectura de obtención de datos tienen efectos medibles en el rendimiento; los mismos principios que se aplican a los widgets de un panel sirven para cualquier frontend que maneje muchos datos.
- Cómo crear una infraestructura de datos preparada para producción para vendedores de Amazon: presentación de tva-fetch — la infraestructura de datos del backend que alimenta un panel determina la calidad de lo que TanStack Query puede almacenar en caché y servir.
- Respuesta a CVE-2025-55182: nuestra experiencia con la vulnerabilidad de React Server Components — la expansión de las capacidades de React en el servidor introduce una superficie de ataque que las arquitecturas de paneles del lado del cliente, como esta, evitan deliberadamente.