Optimistic UI · React Query Mutations · Recharts
Soumettre un exercice, visualiser sa progression, gamifier l'apprentissage
1. Cycle de vie d'une soumission
Comprendre les états : brouillon → soumise → en review → notée
2. Optimistic UI
Mettre à jour l'interface AVANT la confirmation serveur
3. Recharts
Bar chart, line chart, radial chart — API déclarative et responsive
4. Page de progression
Streaks, badges, graphique d'activité — la gamification qui motive
1. Cycle de vie d'une soumission
brouillon → soumise → en review → notée
2. Formulaire de soumission
Lien PR GitHub, commentaire, validation
3. Optimistic UI
onMutate + onError + onSettled avec React Query
4. Data Visualization Recharts
Bar, Line, RadialBar charts
5. Page progression & gamification
Streaks, badges, graphique d'activité hebdomadaire
Cycle de vie d'une soumission
Une soumission n'est pas binaire — elle évolue dans le temps
Chaque état a sa couleur, son icône et son message.
✏️
BROUILLON
En cours de rédaction, pas encore envoyée
draft📤
SOUMISE
Envoyée, en attente de prise en charge
submitted🔍
EN REVIEW
Un formateur la regarde en ce moment
reviewing✅
NOTÉE
Correction disponible + score attribué
gradedModéliser le domaine avant de coder les composants.
type SubmissionStatus = 'draft' | 'submitted' | 'reviewing' | 'graded';
interface Submission {
id: string;
exerciseId: string;
studentId: string;
githubPrUrl: string;
comment?: string;
status: SubmissionStatus;
score?: number; // null tant que status !== 'graded'
feedback?: string; // commentaire du formateur
submittedAt?: string; // ISO date string
gradedAt?: string;
}
Règle métier
score et feedback ne sont disponibles que quand status === 'graded'
Transitions autorisées
draft → submitted → reviewing → graded (jamais en arrière côté étudiant)
Un composant StatusBadge centralisé pour tous les états.
const STATUS_CONFIG = {
draft: { label: 'Brouillon', className: 'bg-slate-700 text-slate-300' },
submitted: { label: 'Soumise', className: 'bg-blue-900/50 text-blue-300' },
reviewing: { label: 'En review', className: 'bg-yellow-900/50 text-yellow-300' },
graded: { label: 'Notée', className: 'bg-green-900/50 text-green-300' },
} as const;
function StatusBadge({ status }: { status: SubmissionStatus }) {
const { label, className } = STATUS_CONFIG[status];
return (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${className}`}>
{label}
</span>
);
}
Principe clé
Centraliser la configuration des états dans un objet évite les if/else éparpillés dans le code.
Formulaire de soumission
Lien PR GitHub · Commentaire · Validation
2 champs : lien PR GitHub (obligatoire) + commentaire (optionnel).
interface SubmissionFormData {
githubPrUrl: string;
comment: string;
}
function SubmissionForm({ exerciseId }: { exerciseId: string }) {
const { register, handleSubmit, formState: { errors } } = useForm<SubmissionFormData>();
const mutation = useSubmitExercise(exerciseId);
const onSubmit = (data: SubmissionFormData) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input
{...register('githubPrUrl', {
required: 'Le lien PR est obligatoire',
pattern: {
value: /^https:\/\/github\.com\/.+\/pull\/\d+$/,
message: 'URL GitHub PR invalide',
},
})}
placeholder="https://github.com/user/repo/pull/42"
className="w-full bg-slate-800 border border-slate-600 rounded px-4 py-2 text-white"
/>
{errors.githubPrUrl && (
<p className="text-red-400 text-sm">{errors.githubPrUrl.message}</p>
)}
</form>
);
}
✗ URLs invalides
https://github.com/user/repo
https://github.com/user/repo/issues/5
http://github.com/user/repo/pull/1
https://gitlab.com/user/repo/pull/1
✓ URLs valides
https://github.com/user/repo/pull/42
https://github.com/bootcamp/exo/pull/1
https://github.com/org/proj/pull/999
// Regex de validation
/^https:\/\/github\.com\/.+\/pull\/\d+$/
Optimistic UI
Mettre à jour l'interface AVANT la confirmation du serveur
Sans Optimistic UI
L'utilisateur attend 300-800ms sans feedback visuel
Avec Optimistic UI
L'interface semble instantanée — 0ms de latence perçue
onMutate
Déclenché avant la requête. Met à jour le cache optimistement. Retourne un contexte pour le rollback.
onError
Si la requête échoue. Restaure le cache avec le contexte de rollback sauvegardé dans onMutate.
onSettled
Toujours déclenché (succès OU erreur). Invalide la query pour re-synchroniser avec le serveur.
Mnémotechnique
onMutate = "Je parie que ça va marcher" → onError = "J'avais tort, je corrige" → onSettled = "On vérifie quoi qu'il arrive"
function useSubmitExercise(exerciseId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SubmissionFormData) =>
api.post(`/exercises/${exerciseId}/submit`, data),
onMutate: async (newData) => {
// 1. Annule les refetch en cours (évite les collisions)
await queryClient.cancelQueries({ queryKey: ['submissions', exerciseId] });
// 2. Snapshot de l'état actuel (pour rollback)
const previousSubmissions = queryClient.getQueryData(['submissions', exerciseId]);
// 3. Mise à jour optimiste du cache
queryClient.setQueryData(['submissions', exerciseId], (old: Submission[]) => [
...old,
{ id: 'temp-' + Date.now(), status: 'submitted', ...newData },
]);
return { previousSubmissions }; // contexte de rollback
},
onError: (_err, _newData, context) => {
// Rollback si erreur
queryClient.setQueryData(['submissions', exerciseId], context?.previousSubmissions);
},
onSettled: () => {
// Toujours re-synchroniser avec le serveur
queryClient.invalidateQueries({ queryKey: ['submissions', exerciseId] });
},
});
}
Commencer par le cas simple — toggle d'un like — avant de passer aux mutations complexes.
function useLikeToggle(submissionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post(`/submissions/${submissionId}/like`),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['submission', submissionId] });
const prev = queryClient.getQueryData<Submission>(['submission', submissionId]);
// Toggle optimiste : si liked → unlike, sinon → like
queryClient.setQueryData(['submission', submissionId], (old: Submission) => ({
...old,
isLiked: !old.isLiked,
likesCount: old.isLiked ? old.likesCount - 1 : old.likesCount + 1,
}));
return { prev };
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['submission', submissionId], context?.prev);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['submission', submissionId] });
},
});
}
Data Visualization avec Recharts
Bar · Line · RadialBar — API déclarative, composants React natifs
Déclaratif
Des composants React comme <BarChart>, <Line> — pas de configuration JSON complexe
Responsive
<ResponsiveContainer> adapte le graphique à la taille du conteneur parent automatiquement
SVG natif
Rendu SVG performant, personnalisable avec des classes CSS ou des props inline
# Installation
npm install recharts
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const weeklyData = [
{ week: 'S1', submissions: 3 },
{ week: 'S2', submissions: 5 },
{ week: 'S3', submissions: 2 },
{ week: 'S4', submissions: 7 },
{ week: 'S5', submissions: 4 },
];
function WeeklySubmissionsChart() {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={weeklyData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="week" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #475569' }}
labelStyle={{ color: '#f8fafc' }}
/>
<Bar dataKey="submissions" fill="#818cf8" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const scoreHistory = [
{ date: '01/03', score: 65 },
{ date: '08/03', score: 72 },
{ date: '15/03', score: 68 },
{ date: '22/03', score: 81 },
{ date: '29/03', score: 88 },
];
function ScoreEvolutionChart() {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={scoreHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9ca3af" />
<YAxis domain={[0, 100]} stroke="#9ca3af" />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #475569' }}
/>
<Line
type="monotone"
dataKey="score"
stroke="#34d399"
strokeWidth={2}
dot={{ fill: '#34d399', strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
import { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } from 'recharts';
function CompletionRadialChart({ percentage }: { percentage: number }) {
const data = [{ value: percentage, fill: '#818cf8' }];
return (
<div className="relative">
<ResponsiveContainer width="100%" height={200}>
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={data}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" cornerRadius={10} background={{ fill: '#1e293b' }} />
</RadialBarChart>
</ResponsiveContainer>
{/* Valeur centrale en overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{percentage}%</span>
</div>
</div>
);
}
Wrapper obligatoire — adapte width/height automatiquement. Toujours utiliser avec width="100%"
Le conteneur du graphique. Prend data (tableau d'objets) et margin en props
dataKey sur XAxis spécifie quelle propriété des données mapper sur l'axe horizontal
dataKey spécifie quelle valeur afficher, fill / stroke pour la couleur
contentStyle pour personnaliser le fond sombre — indispensable sur thème dark
Page de progression & Gamification
Streaks · Badges · Graphique d'activité
🔥
Streak actuel
Jours consécutifs avec au moins une soumission
🏆
Badges débloqués
Jalons atteints : 1ère soumission, 10 exercices, etc.
📊
Graphique activité
Heatmap ou bar chart des 7 derniers jours
Radial Chart
% complétion des exercices de la semaine
Score moyen
Évolution du score sur les 5 dernières semaines
interface StreakData {
currentStreak: number;
longestStreak: number;
lastActivityDate: string;
}
function StreakBadge({ streak }: { streak: StreakData }) {
const isActive = streak.currentStreak > 0;
return (
<div className={`p-4 rounded-xl border-2 ${
isActive ? 'border-orange-500 bg-orange-900/20' : 'border-slate-700 bg-slate-800'
}`}>
<div className="flex items-center gap-3">
<span className="text-4xl">{isActive ? '🔥' : '❄️'}</span>
<div>
<p className={`text-3xl font-bold ${isActive ? 'text-orange-400' : 'text-slate-500'}`}>
{streak.currentStreak} jour{streak.currentStreak > 1 ? 's' : ''}
</p>
<p className="text-slate-400 text-sm">
Record : {streak.longestStreak} jours
</p>
</div>
</div>
</div>
);
}
interface Badge {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: StudentStats) => boolean;
unlockedAt?: string;
}
const BADGES: Badge[] = [
{
id: 'first-submission',
name: 'Premier pas',
description: 'Soumettre son premier exercice',
icon: '🚀',
condition: (stats) => stats.totalSubmissions >= 1,
},
{
id: 'week-streak',
name: 'Une semaine de feu',
description: '7 jours consécutifs avec une soumission',
icon: '🔥',
condition: (stats) => stats.longestStreak >= 7,
},
{
id: 'perfect-score',
name: 'Perfectionniste',
description: 'Obtenir 100/100 sur un exercice',
icon: '⭐',
condition: (stats) => stats.highestScore >= 100,
},
];
function BadgeGrid({ badges, unlockedIds }: { badges: Badge[], unlockedIds: string[] }) {
return (
<div className="grid grid-cols-3 gap-4">
{badges.map((badge) => {
const isUnlocked = unlockedIds.includes(badge.id);
return (
<div
key={badge.id}
className={`p-4 rounded-lg text-center transition-all ${
isUnlocked
? 'bg-yellow-900/30 border-2 border-yellow-500'
: 'bg-slate-800 border-2 border-slate-700 opacity-40 grayscale'
}`}
>
<span className="text-3xl">{badge.icon}</span>
<p className={`font-semibold mt-2 text-sm ${isUnlocked ? 'text-yellow-300' : 'text-slate-500'}`}>
{badge.name}
</p>
<p className="text-slate-500 text-xs mt-1">{badge.description}</p>
</div>
);
})}
</div>
);
}
UX : Les badges non débloqués sont affichés en grisé avec grayscale + opacity-40 — l'utilisateur voit ce qu'il peut obtenir.
⚠️ Ne jamais calculer les streaks côté client
• Le client ne connaît pas toutes les soumissions passées (pagination)
• Les fuseaux horaires varient selon l'utilisateur
• Un utilisateur malveillant peut modifier l'heure du navigateur
• La logique de streak est complexe (minuit, jours fériés, etc.)
✓ L'API retourne directement les valeurs calculées
// Réponse API /api/me/stats
{"{ currentStreak: 5, longestStreak: 12, lastActivityDate: '2024-03-22' }"}
Les erreurs à éviter absolument
✗ Sans rollback
useMutation({
mutationFn: submitExercise,
onMutate: async (data) => {
// Mise à jour optimiste
queryClient.setQueryData(['submissions'],
(old) => [...old, { ...data, status: 'submitted' }]
);
// ❌ Pas de rollback — si erreur :
// l'UI affiche "soumise" alors que ça a échoué !
},
})
✓ Avec rollback
useMutation({
mutationFn: submitExercise,
onMutate: async (data) => {
const prev = queryClient.getQueryData(['submissions']);
queryClient.setQueryData(['submissions'],
(old) => [...old, { ...data, status: 'submitted' }]
);
return { prev }; // ✅ Snapshot sauvegardé
},
onError: (_err, _data, context) => {
queryClient.setQueryData(
['submissions'], context?.prev
); // ✅ Restauration
},
})
✗ Tout charger d'un coup
• 5+ graphiques Recharts sur la même page
• Tous rendus même si hors viewport
• Premier paint lent, TTI dégradé
• Pas d'import() dynamique
✓ Lazy loading des sections
// Charger le composant graphique seulement
// quand il est visible dans le viewport
const ActivityChart = lazy(
() => import('./ActivityChart')
);
function ProgressPage() {
return (
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart />
</Suspense>
);
}
⚠️ L'optimistic UI est délicate à bien faire
Commencer par des cas simples. Complexifier progressivement.
Cas simple
Toggle like / bookmark — 1 champ booléen
Cas intermédiaire
Ajout d'item dans une liste — ID temporaire
Cas complexe
Mutation avec ID serveur, relations imbriquées
Conseil : Si le rollback est trop complexe à implémenter, utiliser un spinner classique. L'optimistic UI doit améliorer l'UX, pas créer des bugs.
🔄 Lifecycle
Une soumission a 4 états. Afficher clairement le statut avec des badges colorés dans STATUS_CONFIG
⚡ Optimistic UI
onMutate → UI immédiate + snapshot, onError → rollback, onSettled → invalidate
📊 Recharts
Toujours wrapper dans ResponsiveContainer. Personnaliser le Tooltip pour le thème dark.
🎮 Gamification
Streaks calculés côté serveur. Badges grisés = motivation. La progression visible = rétention.
Commencer par le toggle "like" pour maîtriser l'optimistic UI, puis monter en complexité.