DS

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"] });
    },
  });
}