Data Loading
Patterns de chargement de donnees, etats de chargement, gestion des erreurs et mises a jour optimistes dans Speedcube Master.
Skeleton loading
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>
);
}TanStack Query
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 pour les donnees temps reel
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;
}Clients Supabase
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);
}Etats d'erreur et vides
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>
}
/>Mises a jour optimistes
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"] });
},
});
}