Patterns de chargement de donnees, etats de chargement, gestion des erreurs et mises a jour optimistes dans Speedcube Master.
Pendant le chargement des donnees, on affiche des squelettes qui reproduisent la forme du contenu final. Cela evite les sauts de layout et donne un retour visuel immediat.
import { Skeleton } from "@/components/ui/skeleton";
// Skeleton pour une carte de statistique
function StatCardSkeleton() {
return (
<Card>
<CardContent className="pt-6 space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
);
}
// Skeleton pour une liste de solves
function SolveListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border border-border">
<Skeleton className="h-6 w-16 font-mono" />
<Skeleton className="h-4 w-48 flex-1" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
);
}
// Skeleton pour le profil utilisateur
function ProfileSkeleton() {
return (
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
);
}Extrait de src/app/(pages)/learning/blind/page.tsx. La page attend le chargement de 3 hooks (stats, letterpairs, solves) avant d'afficher le contenu. En attendant, un skeleton reproduit la structure exacte du layout : un titre, une grille de 3 cartes, et une zone de contenu.
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import { useBldStats } from "@/hooks/use-bld-stats";
import { useBldLetterpairs } from "@/hooks/use-bld-letterpairs";
import { useBldSolves } from "@/hooks/use-bld-solves";
export default function BlindLearningPage() {
const { stats, loading: statsLoading } = useBldStats();
const { letterpairs, loading: letterpairsLoading } = useBldLetterpairs();
const { solves, loading: solvesLoading } = useBldSolves(3);
// Combiner les etats de chargement
const loading = statsLoading || letterpairsLoading || solvesLoading;
// Skeleton qui reproduit la structure exacte de la page
if (loading) {
return (
<div className="container mx-auto px-4 pt-16 sm:pt-20 pb-8 max-w-6xl">
<Skeleton className="h-12 w-64 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="container mx-auto px-4 pt-16 sm:pt-20 pb-8 max-w-6xl">
{/* Contenu reel de la page */}
</div>
);
}Extrait de src/app/page.tsx. Les composants lourds sont charges avec next/dynamic. Le parametre loading affiche un skeleton qui imite la structure du composant final pendant le telechargement du chunk JS.
import dynamic from "next/dynamic";
// Chargement dynamique avec skeleton de remplacement
const LatestArticlesSection = dynamic(
() => import("@/components/latest-articles-section")
.then(m => ({ default: m.LatestArticlesSection })),
{
ssr: true, // Activer SSR pour rendu immediat
loading: () => (
<section className="py-20 px-3 sm:px-4 lg:px-6 bg-muted/40 border-t border-border/60">
<div className="mx-auto max-w-6xl">
{/* Skeleton du titre de section */}
<div className="text-center mb-12">
<div className="h-8 w-48 bg-muted rounded animate-pulse mx-auto mb-4" />
<div className="h-4 w-96 bg-muted rounded animate-pulse mx-auto" />
</div>
{/* Skeleton de la grille d'articles (3 colonnes) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-card border border-border rounded-lg p-4">
<div className="h-48 w-full bg-muted rounded animate-pulse mb-4" />
<div className="h-4 w-3/4 bg-muted rounded animate-pulse mb-2" />
<div className="h-3 w-full bg-muted rounded animate-pulse mb-2" />
<div className="h-3 w-5/6 bg-muted rounded animate-pulse mb-4" />
<div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
</div>
))}
</div>
</div>
</section>
),
}
);
// Composant Hero avec SSR et skeleton simple
const HeroPreview = dynamic(
() => import("@/components/hero-preview-simple")
.then(m => ({ default: m.HeroPreviewSimple })),
{
ssr: true,
loading: () => (
<div className="relative mt-8 lg:mt-0 h-[300px] sm:h-[350px] lg:h-[400px]
bg-card/50 rounded-2xl" />
),
}
);TanStack Query (React Query) est utilise pour les requetes de donnees avec cache, revalidation automatique et gestion d'etat.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Hook de requete avec TanStack Query
function useSolves(sessionId: string) {
return useQuery({
queryKey: ["solves", sessionId],
queryFn: async () => {
const response = await fetch(`/api/solves?sessionId=${sessionId}`);
if (!response.ok) throw new Error("Erreur de chargement");
return response.json() as Promise<Solve[]>;
},
staleTime: 30 * 1000, // 30 secondes avant revalidation
refetchOnWindowFocus: true,
});
}
// Utilisation dans un composant
function SolveList({ sessionId }: { sessionId: string }) {
const { data: solves, isLoading, error } = useSolves(sessionId);
if (isLoading) return <SolveListSkeleton />;
if (error) return <ErrorState message="Impossible de charger les solves" />;
if (!solves?.length) return <EmptyState message="Aucune solve pour le moment" />;
return (
<div className="space-y-2">
{solves.map((solve) => (
<SolveCard key={solve.id} solve={solve} />
))}
</div>
);
}SWR est utilise pour les donnees qui doivent etre rafraichies frequemment, comme les salles multijoueurs ou le classement en temps reel.
import useSWR from "swr";
const fetcher = (url: string) =>
fetch(url).then((res) => res.json());
// Donnees temps reel avec polling
function useRoomParticipants(roomId: string) {
return useSWR(
roomId ? `/api/rooms/${roomId}/participants` : null,
fetcher,
{
refreshInterval: 3000, // Refresh toutes les 3 secondes
revalidateOnFocus: true,
dedupingInterval: 1000,
}
);
}
// Donnees temps reel avec Supabase Realtime
function useRealtimeSolves(sessionId: string) {
const [solves, setSolves] = useState<Solve[]>([]);
useEffect(() => {
const client = getSupabaseClient();
// Charger les donnees initiales
client
.from("solves")
.select("*")
.eq("session_id", sessionId)
.order("created_at", { ascending: false })
.then(({ data }) => setSolves(data || []));
// Ecouter les changements en temps reel
const channel = client
.channel(`solves:${sessionId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "solves",
filter: `session_id=eq.${sessionId}`,
},
(payload) => {
if (payload.eventType === "INSERT") {
setSolves((prev) => [payload.new as Solve, ...prev]);
} else if (payload.eventType === "DELETE") {
setSolves((prev) =>
prev.filter((s) => s.id !== payload.old.id)
);
}
}
)
.subscribe();
return () => {
client.removeChannel(channel);
};
}, [sessionId]);
return solves;
}Speedcube Master utilise deux types de clients Supabase selon le contexte : un client anonyme pour les donnees publiques et un client authentifie pour les donnees utilisateur.
import {
getSupabaseClient,
createSupabaseClientWithUser,
} from "@/lib/supabase";
// Client anonyme (donnees publiques, pas de RLS)
const client = getSupabaseClient();
const { data: algorithms } = await client
.from("algorithms")
.select("*")
.eq("cube_type", "3x3");
// Client authentifie (donnees utilisateur, RLS actif)
// Le header X-User-ID est envoye pour les politiques RLS
const userClient = createSupabaseClientWithUser(userId);
const { data: solves } = await userClient
.from("solves")
.select("*")
.order("created_at", { ascending: false });
// Dans une API Route
export async function GET(request: Request) {
const userId = request.headers.get("X-User-ID");
if (!userId) {
return Response.json({ error: "Non authentifie" }, { status: 401 });
}
const client = createSupabaseClientWithUser(userId);
const { data, error } = await client
.from("sessions")
.select("*, solves(count)")
.order("created_at", { ascending: false });
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
return Response.json(data);
}Extrait de src/app/(pages)/classement/page.tsx. Ce pattern montre comment charger des donnees depuis plusieurs tables Supabase, les combiner cote client, puis gerer les etats loading et donnees vides.
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Trophy, Medal, Award } from "lucide-react";
import { getSupabaseClient } from "@/lib/supabase";
interface UserScore {
user_id: string;
username: string;
avatar_url?: string;
algorithms_count: number;
alternatives_count: number;
methods_count: number;
total_score: number;
}
export default function LeaderboardPage() {
const [leaderboard, setLeaderboard] = useState<UserScore[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLeaderboard = async () => {
try {
const supabase = getSupabaseClient();
// Requetes paralleles sur plusieurs tables
const [algoRes, altRes, methodRes] = await Promise.all([
supabase.from("algorithms").select("created_by").eq("status", "approved"),
supabase.from("algorithm_alternatives").select("created_by").eq("status", "approved"),
supabase.from("methods").select("created_by").eq("status", "approved"),
]);
// Combiner les contributions par utilisateur
const contributions = new Map<string, { algos: number; alts: number; methods: number }>();
algoRes.data?.forEach((algo) => {
if (!algo.created_by) return;
const current = contributions.get(algo.created_by) || { algos: 0, alts: 0, methods: 0 };
contributions.set(algo.created_by, { ...current, algos: current.algos + 1 });
});
// ... meme pattern pour altRes et methodRes ...
// Recuperer les profils correspondants
const userIds = Array.from(contributions.keys());
const { data: profiles } = await supabase
.from("profiles")
.select("id, username, avatar_url, custom_avatar_url")
.in("id", userIds);
// Calculer les scores et trier
const scores: UserScore[] = (profiles || []).map((profile) => {
const c = contributions.get(profile.id) || { algos: 0, alts: 0, methods: 0 };
return {
user_id: profile.id,
username: profile.username || "Anonyme",
avatar_url: profile.custom_avatar_url || profile.avatar_url,
algorithms_count: c.algos,
alternatives_count: c.alts,
methods_count: c.methods,
total_score: c.algos * 5 + c.alts * 1 + c.methods * 50,
};
});
setLeaderboard(scores.sort((a, b) => b.total_score - a.total_score));
} finally {
setLoading(false);
}
};
fetchLeaderboard();
}, []);
// Pattern : loading → skeleton, erreur → message, vide → message, sinon → contenu
if (loading) {
return <LeaderboardSkeleton />;
}
if (leaderboard.length === 0) {
return <EmptyState message="Aucun contributeur pour le moment" />;
}
return (
<div className="space-y-4">
{leaderboard.map((user, index) => (
<Card key={user.user_id}>
{/* ... affichage de l'utilisateur ... */}
</Card>
))}
</div>
);
}Chaque zone de donnees doit gerer trois etats : chargement, erreur et donnees vides.
// Composant d'etat d'erreur
function ErrorState({
message = "Une erreur est survenue",
onRetry,
}: {
message?: string;
onRetry?: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-destructive/10 p-3 mb-4">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<p className="text-sm text-muted-foreground mb-4">{message}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
Reessayer
</Button>
)}
</div>
);
}
// Composant d'etat vide
function EmptyState({
icon: Icon = Inbox,
title = "Aucune donnee",
message,
action,
}: {
icon?: LucideIcon;
title?: string;
message?: string;
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium mb-1">{title}</h3>
{message && (
<p className="text-xs text-muted-foreground mb-4">{message}</p>
)}
{action}
</div>
);
}
// Utilisation
<EmptyState
icon={Timer}
title="Aucune solve"
message="Commencez a resoudre pour voir vos temps ici."
action={
<Button size="sm" asChild>
<Link href="/timer">Aller au timer</Link>
</Button>
}
/>Pour une experience fluide, certaines actions mettent a jour l'interface immediatement avant la confirmation du serveur.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useDeleteSolve() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (solveId: string) => {
const response = await fetch(`/api/solves/${solveId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Erreur de suppression");
},
// Mise a jour optimiste : supprimer du cache immediatement
onMutate: async (solveId) => {
// Annuler les requetes en cours pour eviter les conflits
await queryClient.cancelQueries({ queryKey: ["solves"] });
// Sauvegarder l'etat precedent pour le rollback
const previousSolves = queryClient.getQueryData(["solves"]);
// Retirer la solve du cache
queryClient.setQueryData<Solve[]>(["solves"], (old) =>
old?.filter((s) => s.id !== solveId) ?? []
);
return { previousSolves };
},
// En cas d'erreur, restaurer l'etat precedent
onError: (_err, _solveId, context) => {
queryClient.setQueryData(["solves"], context?.previousSolves);
toast.error("Impossible de supprimer la solve");
},
// Revalider apres la mutation
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["solves"] });
},
});
}