Patterns de gestion des etats d'erreur, etats vides et fallbacks dans Speedcube Master.
Quand une zone de donnees ne contient aucun element, on affiche un etat vide compose d'une icone, d'un message explicatif et d'un bouton d'action (CTA) pour guider l'utilisateur.
Commencez a resoudre pour voir vos temps ici.
import { Inbox, Timer } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
// Composant reutilisable 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>
);
}
// Exemples d'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>
}
/>
<EmptyState
icon={Search}
title="Aucun resultat"
message="Essayez de modifier vos filtres ou votre recherche."
/>
<EmptyState
icon={BookOpen}
title="Aucun algorithme"
message="Cette methode n'a pas encore d'algorithmes."
action={
<Button size="sm" variant="outline" asChild>
<Link href="/algos/contribute">Contribuer</Link>
</Button>
}
/>Next.js gere nativement les pages introuvables via le fichier not-found.tsx. Ce fichier peut etre place a la racine de app/ ou dans n'importe quel segment de route pour un 404 contextuel. On peut aussi declencher un 404 programmatiquement avec notFound().
// app/not-found.tsx — Page 404 globale
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<h1 className="text-6xl font-bold text-primary mb-4">404</h1>
<h2 className="text-xl font-semibold mb-2">Page introuvable</h2>
<p className="text-muted-foreground mb-8 max-w-md">
La page que vous cherchez n'existe pas ou a ete deplacee.
</p>
<Button asChild>
<Link href="/">Retour a l'accueil</Link>
</Button>
</div>
);
}
// Declenchement programmatique dans un Server Component
import { notFound } from "next/navigation";
export default async function SolvePage({ params }: { params: { id: string } }) {
const solve = await getSolve(params.id);
if (!solve) {
notFound(); // Affiche le not-found.tsx le plus proche
}
return <SolveDetail solve={solve} />;
}Next.js App Router utilise le fichier error.tsx comme error boundary React. Ce composant doit etre un Client Component et recoit l'erreur ainsi qu'une fonction reset() pour retenter le rendu.
"use client";
// app/(pages)/timer/error.tsx — Error boundary pour la section timer
import { useEffect } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function TimerError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Logger l'erreur dans un service de monitoring
console.error("Timer error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<div className="rounded-full bg-destructive/10 p-4 mb-6">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold mb-2">
Quelque chose s'est mal passe
</h2>
<p className="text-muted-foreground mb-6 max-w-md">
Une erreur inattendue est survenue. Vous pouvez reessayer ou
revenir a l'accueil.
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={reset}>
<RefreshCw className="h-4 w-4 mr-2" />
Reessayer
</Button>
<Button asChild>
<a href="/">Accueil</a>
</Button>
</div>
</div>
);
}
// app/global-error.tsx — Error boundary pour le layout racine
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body className="bg-[#0B0F1A] text-white">
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-xl font-semibold mb-4">Erreur critique</h2>
<button
onClick={reset}
className="px-4 py-2 rounded-md bg-primary text-white"
>
Reessayer
</button>
</div>
</body>
</html>
);
}Les appels API sont enveloppes dans des blocs try/catch. En cas d'erreur, un toast Sonner informe l'utilisateur et un etat de retry est propose quand c'est pertinent.
import { toast } from "sonner";
// Pattern try/catch avec toast pour les erreurs API
async function deleteSolve(solveId: string) {
try {
const response = await fetch(`/api/solves/${solveId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
toast.success("Solve supprimee");
} catch (error) {
toast.error("Impossible de supprimer la solve", {
description: "Verifiez votre connexion et reessayez.",
action: {
label: "Reessayer",
onClick: () => deleteSolve(solveId),
},
});
}
}
// Pattern avec etat de retry dans un hook
function useSolves(sessionId: string) {
const [data, setData] = useState<Solve[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const fetchSolves = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/solves?sessionId=${sessionId}`);
if (!response.ok) throw new Error("Erreur de chargement");
const solves = await response.json();
setData(solves);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
toast.error("Impossible de charger les solves");
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
fetchSolves();
}, [fetchSolves]);
return { data, error, loading, retry: fetchSolves };
}
// Utilisation avec affichage d'erreur et retry
function SolveList({ sessionId }: { sessionId: string }) {
const { data, error, loading, retry } = useSolves(sessionId);
if (loading) return <SolveListSkeleton />;
if (error) {
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">{error}</p>
<Button variant="outline" size="sm" onClick={retry}>
<RefreshCw className="h-4 w-4 mr-2" />
Reessayer
</Button>
</div>
);
}
if (!data.length) {
return <EmptyState message="Aucune solve pour le moment" />;
}
return (
<div className="space-y-2">
{data.map((solve) => (
<SolveCard key={solve.id} solve={solve} />
))}
</div>
);
}Les erreurs de validation sont gerees par react-hook-form + Zod. Les messages d'erreur s'affichent sous chaque champ invalide avec un style destructif. Pour les details complets sur les patterns de formulaires, voir la page Forms.
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// Schema Zod avec messages d'erreur personnalises
const sessionSchema = z.object({
name: z
.string()
.min(1, "Le nom est requis")
.max(50, "Le nom ne doit pas depasser 50 caracteres"),
cube_type: z.enum(["2x2", "3x3", "4x4", "5x5"], {
errorMap: () => ({ message: "Selectionnez un type de cube" }),
}),
});
type SessionFormData = z.infer<typeof sessionSchema>;
function CreateSessionForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SessionFormData>({
resolver: zodResolver(sessionSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom de la session</Label>
<Input
id="name"
{...register("name")}
className={errors.name ? "border-destructive" : ""}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
{/* ... autres champs ... */}
<Button type="submit">Creer la session</Button>
</form>
);
}