Dashboard React

Skeleton Loading · Polling · React Query · Temps réel

Construire un hub central avec TodayLesson, TodayExercise, PairCard et StreakBadge

Objectifs de la leçon

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

Plan du cours

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

Module 1

Le Dashboard comme hub central

Un seul écran, toutes les infos essentielles

Pourquoi un Dashboard ?

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

Architecture du Dashboard

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

Le composant Dashboard

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>
  );
}

Règle d'or du Dashboard

⛔ 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

Module 2

Skeleton Loading

Des placeholders animés pour chaque composant

Qu'est-ce qu'un Skeleton ?

Un placeholder qui imite la forme du contenu final.

Skeleton (chargement)

Contenu réel (chargé)

React avancé

Custom hooks et composition

Chapitre 3 / 8

Pourquoi le Skeleton est supérieur au Spinner

🧠

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.

Anatomie d'un Skeleton avec Tailwind

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

TodayLessonSkeleton — Exemple complet

// 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>
  );
}

TodayLesson — Le composant réel

// 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>
  );
}

PairCardSkeleton & StreakBadgeSkeleton

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>
  );
}

Pattern : Skeleton dans le même fichier vs fichier séparé

✓ 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é

Astuce : Skeleton générique avec variantes

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" />

Module 3

React Query & Polling

Gérer le cache, le refetch et le polling automatique

Pourquoi React Query ?

Gérer l'état serveur sans le réinventer à chaque fois.

✗ Sans React Query

  • • useState + useEffect pour chaque requête
  • • Gestion manuelle du loading/error
  • • Pas de cache → requêtes en double
  • • Pas de polling automatique
  • • Race conditions non gérées

✓ Avec React Query

  • • 1 hook = data + loading + error
  • • Cache intelligent automatique
  • • Deduplication des requêtes
  • • Polling en 1 ligne (refetchInterval)
  • • Retry automatique sur erreur

Le hook useTodayLesson 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.

staleTime vs gcTime (ex-cacheTime)

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

Chronologie du cache React Query

t=0s

Fetch initial → données en cache (FRESH)

t=5min

staleTime expiré → données STALE

Prochain accès déclenchera un refetch en arrière-plan

t=30s

refetchInterval → re-fetch automatique (polling)

Indépendant du staleTime — se déclenche toujours

unmount

Composant démonté → timer gcTime démarre

Après gcTime (5min) → données supprimées du cache

Tous les hooks du Dashboard

// 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)
  });
}

Polling conditionnel

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.

Le Dashboard complet avec React Query

// 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>
  );
}

Piège : Trop de requêtes au chargement

✗ 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

Solution : Endpoint agrégé + select

// 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.

Module 4

SSE & WebSocket

Quand le polling ne suffit plus

Polling vs SSE vs WebSocket

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

SSE — Server-Sent Events

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);
  });
}

Quand utiliser quoi ?

🔄 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

Combiner Polling + SSE dans React Query

// 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.

Module 5

Layout Responsive & Composition finale

Assembler le tout avec un grid responsive

Grid Responsive : 1 → 2 → 3 colonnes

// 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

Dashboard final complet

// 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>
  );
}

Pièges courants & Solutions

⚠️

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

Résumé : Config React Query pour le Dashboard

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

Points clés à retenir

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