Skeleton Loading · Polling · React Query · Temps réel
Construire un hub central avec TodayLesson, TodayExercise, PairCard et StreakBadge
1. Dashboard fonctionnel
Toutes les cartes du jour visibles en un coup d'œil
2. Skeleton loading
Chaque carte a son propre placeholder animé
3. Polling automatique
React Query met à jour les données toutes les 30s
4. Layout responsive
1 colonne mobile, 3 colonnes desktop
1. Le Dashboard comme hub central
Toutes les infos du jour en un coup d'œil
2. Composition de composants
TodayLesson, TodayExercise, PairCard, StreakBadge
3. Skeleton loading
Afficher des placeholders pendant le chargement
4. React Query & Polling
staleTime, refetchInterval pour le polling
5. SSE & WebSocket
Quand utiliser quoi pour le temps réel
Le Dashboard comme hub central
Un seul écran, toutes les infos essentielles
L'utilisateur ne devrait jamais chercher l'info — elle vient à lui.
TodayLesson
La leçon du jour avec son titre et sa progression
TodayExercise
L'exercice en cours avec deadline et statut
PairCard
Le binôme du jour pour le pair programming
StreakBadge
Le nombre de jours consécutifs de code
Chaque carte est un composant autonome avec ses propres données.
// Structure des fichiers
src/
pages/
Dashboard.tsx
components/dashboard/
TodayLesson.tsx
TodayLessonSkeleton.tsx
TodayExercise.tsx
TodayExerciseSkeleton.tsx
PairCard.tsx
PairCardSkeleton.tsx
StreakBadge.tsx
StreakBadgeSkeleton.tsx
hooks/
useTodayLesson.ts
useTodayExercise.ts
usePair.ts
useStreak.ts
Simple composition — chaque carte gère son propre loading.
// pages/Dashboard.tsx
export default function Dashboard() {
return (
<div className="min-h-screen bg-slate-900 p-6">
<h1 className="text-3xl font-bold text-white mb-8">
Bonjour ! 👋
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<TodayLesson />
<TodayExercise />
<PairCard />
<StreakBadge />
</div>
</div>
);
}
⛔ JAMAIS d'écran blanc
Le dashboard ne doit JAMAIS afficher un écran vide pendant le chargement
✗ MAUVAIS
Loading...
Un seul spinner pour tout le dashboard
✓ CORRECT
Chaque carte a son propre skeleton
Skeleton Loading
Des placeholders animés pour chaque composant
Un placeholder qui imite la forme du contenu final.
Skeleton (chargement)
Contenu réel (chargé)
React avancé
Custom hooks et composition
Chapitre 3 / 8
🧠
Perception
L'utilisateur perçoit que le contenu charge plus vite
📐
Layout stable
Pas de "jump" quand le contenu arrive (CLS = 0)
🎯
Contexte
L'utilisateur sait quel type de contenu va apparaître
💡 Études UX
Les skeletons réduisent la perception du temps d'attente de ~35% par rapport à un spinner classique.
La classe animate-pulse fait tout le travail.
// components/ui/Skeleton.tsx
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`
animate-pulse
bg-slate-700
rounded
${className}
`}
/>
);
}
Utilisation :
<Skeleton className="h-6 w-3/4" />
// → rectangle animé de 24px de haut, 75% de large
// components/dashboard/TodayLessonSkeleton.tsx
import { Skeleton } from "../ui/Skeleton";
export function TodayLessonSkeleton() {
return (
<div className="bg-slate-800 rounded-xl p-6 space-y-4">
{/* Icône + badge */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
{/* Titre */}
<Skeleton className="h-6 w-3/4" />
{/* Description */}
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
{/* Barre de progression */}
<Skeleton className="h-3 w-full rounded-full" />
{/* Bouton */}
<Skeleton className="h-10 w-32 rounded-lg" />
</div>
);
}
// components/dashboard/TodayLesson.tsx
import { useTodayLesson } from "../../hooks/useTodayLesson";
import { TodayLessonSkeleton } from "./TodayLessonSkeleton";
export function TodayLesson() {
const { data: lesson, isLoading } = useTodayLesson();
if (isLoading) return <TodayLessonSkeleton />;
return (
<div className="bg-slate-800 rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<span className="text-2xl">📚</span>
<span className="text-xs bg-indigo-600 text-white
px-2 py-1 rounded-full">
{lesson.chapter}/{lesson.totalChapters}
</span>
</div>
<h3 className="text-xl font-bold text-white">
{lesson.title}
</h3>
<p className="text-slate-400">{lesson.description}</p>
{/* Barre de progression */}
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${lesson.progress}%` }}
/>
</div>
<button className="bg-indigo-600 hover:bg-indigo-500
text-white px-4 py-2 rounded-lg">
Continuer
</button>
</div>
);
}
Chaque carte a un skeleton qui correspond à sa structure.
// PairCardSkeleton.tsx
export function PairCardSkeleton() {
return (
<div className="bg-slate-800 rounded-xl p-6
space-y-4">
{/* Avatar */}
<Skeleton className="h-16 w-16
rounded-full mx-auto" />
{/* Nom */}
<Skeleton className="h-5 w-1/2 mx-auto" />
{/* Info */}
<Skeleton className="h-4 w-3/4 mx-auto" />
{/* Bouton */}
<Skeleton className="h-9 w-full
rounded-lg" />
</div>
);
}
// StreakBadgeSkeleton.tsx
export function StreakBadgeSkeleton() {
return (
<div className="bg-slate-800 rounded-xl p-6
text-center space-y-3">
{/* Flamme emoji */}
<Skeleton className="h-12 w-12
rounded-full mx-auto" />
{/* Nombre */}
<Skeleton className="h-8 w-16 mx-auto" />
{/* Label */}
<Skeleton className="h-4 w-24 mx-auto" />
</div>
);
}
✓ Fichier séparé (recommandé)
TodayLesson.tsx
TodayLessonSkeleton.tsx
+ Séparation des responsabilités
+ Réutilisable dans Storybook
+ Testable isolément
⚠ Même fichier (acceptable)
TodayLesson.tsx (export + skeleton)
+ Colocation du skeleton avec son composant
- Fichier plus long
- Moins de granularité
Un composant flexible pour éviter la répétition.
// components/ui/Skeleton.tsx
type SkeletonVariant = "text" | "title" | "avatar" | "button" | "bar";
const variants: Record<SkeletonVariant, string> = {
text: "h-4 w-full",
title: "h-6 w-3/4",
avatar: "h-12 w-12 rounded-full",
button: "h-10 w-32 rounded-lg",
bar: "h-3 w-full rounded-full",
};
interface SkeletonProps {
variant?: SkeletonVariant;
className?: string;
}
export function Skeleton({ variant = "text", className = "" }: SkeletonProps) {
return (
<div className={`animate-pulse bg-slate-700 rounded ${variants[variant]} ${className}`} />
);
}
// Utilisation simplifiée
<Skeleton variant="title" />
<Skeleton variant="text" />
<Skeleton variant="avatar" />
<Skeleton variant="button" />
React Query & Polling
Gérer le cache, le refetch et le polling automatique
Gérer l'état serveur sans le réinventer à chaque fois.
✗ Sans React Query
✓ Avec React Query
// hooks/useTodayLesson.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
interface Lesson {
id: string;
title: string;
description: string;
chapter: number;
totalChapters: number;
progress: number;
}
export function useTodayLesson() {
return useQuery<Lesson>({
queryKey: ["today-lesson"],
queryFn: () => api.get("/api/today/lesson").then(res => res.data),
// Données considérées "fraîches" pendant 5 minutes
staleTime: 5 * 60 * 1000,
// Polling : re-fetch toutes les 30 secondes
refetchInterval: 30 * 1000,
});
}
💡 Clé importante
Le queryKey identifie la requête dans le cache. Deux composants utilisant la même clé partagent le même cache.
Deux concepts souvent confondus par les débutants.
staleTime
Combien de temps les données sont considérées "fraîches"
Tant que les données sont fraîches → pas de re-fetch
Défaut : 0 (toujours stale → re-fetch à chaque mount)
gcTime (Garbage Collection Time)
Combien de temps garder les données en cache après qu'elles soient inutilisées
Aucun composant n'observe cette query → timer démarre
Défaut : 5 minutes
Fetch initial → données en cache (FRESH)
staleTime expiré → données STALE
Prochain accès déclenchera un refetch en arrière-plan
refetchInterval → re-fetch automatique (polling)
Indépendant du staleTime — se déclenche toujours
Composant démonté → timer gcTime démarre
Après gcTime (5min) → données supprimées du cache
// hooks/useTodayExercise.ts
export function useTodayExercise() {
return useQuery<Exercise>({
queryKey: ["today-exercise"],
queryFn: () => api.get("/api/today/exercise").then(r => r.data),
staleTime: 2 * 60 * 1000, // 2 min (change plus souvent)
refetchInterval: 30 * 1000, // polling 30s
});
}
// hooks/usePair.ts
export function usePair() {
return useQuery<Pair>({
queryKey: ["today-pair"],
queryFn: () => api.get("/api/today/pair").then(r => r.data),
staleTime: 30 * 60 * 1000, // 30 min (change rarement)
refetchInterval: 60 * 1000, // polling 60s
});
}
// hooks/useStreak.ts
export function useStreak() {
return useQuery<Streak>({
queryKey: ["streak"],
queryFn: () => api.get("/api/me/streak").then(r => r.data),
staleTime: 10 * 60 * 1000, // 10 min
refetchInterval: 5 * 60 * 1000, // polling 5 min (pas urgent)
});
}
Arrêter le polling quand l'onglet est en arrière-plan.
// hooks/useTodayExercise.ts
export function useTodayExercise() {
return useQuery<Exercise>({
queryKey: ["today-exercise"],
queryFn: () => api.get("/api/today/exercise").then(r => r.data),
// Polling uniquement quand l'onglet est visible
refetchInterval: 30 * 1000,
refetchIntervalInBackground: false, // ← IMPORTANT
// Re-fetch quand l'utilisateur revient sur l'onglet
refetchOnWindowFocus: true,
});
}
💡 Optimisation bande passante
refetchIntervalInBackground: false évite les requêtes inutiles quand l'utilisateur ne regarde pas la page.
// pages/Dashboard.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TodayLesson } from "../components/dashboard/TodayLesson";
import { TodayExercise } from "../components/dashboard/TodayExercise";
import { PairCard } from "../components/dashboard/PairCard";
import { StreakBadge } from "../components/dashboard/StreakBadge";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 2,
refetchOnWindowFocus: true,
},
},
});
export default function Dashboard() {
return (
<QueryClientProvider client={queryClient}>
<div className="min-h-screen bg-slate-900 p-6">
<h1 className="text-3xl font-bold text-white mb-8">
Bonjour ! 👋
</h1>
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6">
<TodayLesson />
<TodayExercise />
<PairCard />
<StreakBadge />
</div>
</div>
</QueryClientProvider>
);
}
✗ PROBLÈME
// 4 requêtes en parallèle au mount
GET /api/today/lesson
GET /api/today/exercise
GET /api/today/pair
GET /api/me/streak
4 requêtes simultanées → lent sur réseau mobile
✓ SOLUTION
// Un seul endpoint agrégé
GET /api/dashboard
// OU prioriser les requêtes
// critiques d'abord
lesson + exercise → immédiat
pair + streak → après 1s
Moins de requêtes ou chargement progressif
// hooks/useDashboard.ts — endpoint unique
interface DashboardData {
lesson: Lesson;
exercise: Exercise;
pair: Pair;
streak: Streak;
}
function useDashboard() {
return useQuery<DashboardData>({
queryKey: ["dashboard"],
queryFn: () => api.get("/api/dashboard").then(r => r.data),
staleTime: 2 * 60 * 1000,
refetchInterval: 30 * 1000,
});
}
// Sélecteurs pour chaque composant
export function useTodayLesson() {
return useDashboard({ select: (data) => data.lesson });
}
export function useTodayExercise() {
return useDashboard({ select: (data) => data.exercise });
}
💡 select
Le select ne re-render le composant que si SA partie des données change.
SSE & WebSocket
Quand le polling ne suffit plus
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client → Serveur | Serveur → Client | Bidirectionnel |
| Protocole | HTTP (répété) | HTTP (connexion longue) | WS (upgrade HTTP) |
| Latence | Intervalle/2 en moyenne | Temps réel | Temps réel |
| Complexité | ⭐ Très simple | ⭐⭐ Simple | ⭐⭐⭐ Complexe |
| Use case | Dashboard, stats | Notifications, feed | Chat, jeux, collab |
Le serveur envoie des événements au client via une connexion HTTP longue.
// hooks/useSSE.ts — Hook générique pour SSE
export function useSSE<T>(url: string, onMessage: (data: T) => void) {
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
const data: T = JSON.parse(event.data);
onMessage(data);
};
eventSource.onerror = () => {
// Reconnexion automatique par le navigateur
console.error("SSE connection lost, reconnecting...");
};
return () => eventSource.close();
}, [url, onMessage]);
}
// Utilisation avec React Query
function useLessonUpdates() {
const queryClient = useQueryClient();
useSSE("/api/events/lesson", (lesson) => {
// Mettre à jour le cache React Query directement
queryClient.setQueryData(["today-lesson"], lesson);
});
}
🔄 Polling (refetchInterval)
Dashboard, statistiques, données qui changent lentement
→ Notre cas : polling 30s est parfait pour le dashboard
📡 SSE (Server-Sent Events)
Notifications en temps réel, flux d'activité, mises à jour one-way
→ Quand le binôme est assigné, notifier immédiatement
🔌 WebSocket
Chat en temps réel, éditeur collaboratif, jeux multijoueur
→ Overkill pour un dashboard, mais essentiel pour le pair coding live
// hooks/usePair.ts — Polling + SSE pour PairCard
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function usePair() {
const queryClient = useQueryClient();
// SSE pour les mises à jour immédiates
useEffect(() => {
const es = new EventSource("/api/events/pair");
es.onmessage = (e) => {
queryClient.setQueryData(["today-pair"], JSON.parse(e.data));
};
return () => es.close();
}, [queryClient]);
// Polling comme fallback si SSE se déconnecte
return useQuery<Pair>({
queryKey: ["today-pair"],
queryFn: () => api.get("/api/today/pair").then(r => r.data),
staleTime: 30 * 60 * 1000,
refetchInterval: 60 * 1000, // Fallback polling
});
}
💡 Best practice
SSE met à jour le cache immédiatement. Le polling sert de filet de sécurité si la connexion SSE tombe.
Layout Responsive & Composition finale
Assembler le tout avec un grid responsive
// Le grid du Dashboard
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Prend 2 colonnes sur desktop */}
<div className="lg:col-span-2">
<TodayLesson />
</div>
{/* 1 colonne */}
<StreakBadge />
{/* 1 colonne */}
<TodayExercise />
{/* 1 colonne */}
<PairCard />
</div>
Breakpoints Tailwind :
< 768px
1 colonne
Mobile
md: 768px+
2 colonnes
Tablette
lg: 1024px+
3 colonnes
Desktop
// pages/Dashboard.tsx — Version finale
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
function DashboardErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="bg-red-900/30 border border-red-500 rounded-xl p-6 text-center">
<p className="text-red-400 font-bold">Erreur de chargement</p>
<p className="text-slate-400 text-sm mt-2">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-4 bg-red-600 text-white px-4 py-2 rounded-lg"
>
Réessayer
</button>
</div>
);
}
export default function Dashboard() {
return (
<div className="min-h-screen bg-slate-900 p-4 md:p-6 lg:p-8">
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6 md:mb-8">
Bonjour ! 👋
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
<ErrorBoundary FallbackComponent={DashboardErrorFallback}>
<div className="lg:col-span-2">
<TodayLesson />
</div>
<StreakBadge />
<TodayExercise />
<PairCard />
</ErrorBoundary>
</div>
</div>
);
}
Flash de contenu vide
Oubli du skeleton → l'utilisateur voit un blanc puis le contenu apparaît
✓ Toujours retourner un skeleton quand isLoading est true
Confusion staleTime / gcTime
staleTime = quand re-fetcher · gcTime = quand supprimer du cache
✓ staleTime contrôle la fraîcheur, gcTime contrôle la mémoire
Trop de requêtes parallèles
4+ requêtes en simultané sur réseau lent = expérience dégradée
✓ Endpoint agrégé /api/dashboard OU prioriser les requêtes critiques
| Hook | staleTime | refetchInterval | Pourquoi |
|---|---|---|---|
| useTodayLesson | 5 min | 30s | Progression change en cours de leçon |
| useTodayExercise | 2 min | 30s | Deadline et statut changent souvent |
| usePair | 30 min | 60s | Change 1x/jour max |
| useStreak | 10 min | 5 min | Change 1x/jour — pas urgent |
1. Skeleton first
Chaque composant qui fetch doit avoir un skeleton correspondant à sa forme
2. React Query = cache intelligent
staleTime évite les requêtes inutiles, refetchInterval fait le polling
3. Polling pour le dashboard
30s est suffisant pour la plupart des cartes — pas besoin de WebSocket
4. Layout responsive
grid-cols-1 → md:grid-cols-2 → lg:grid-cols-3 avec col-span pour les cartes larges
5. SSE > Polling quand c'est urgent
Pour les notifications ou changements critiques, combiner SSE + polling comme fallback