En travaillant sur Typewriter, un test de vitesse de frappe construit en Next.js et TypeScript, je me suis rendu compte que la partie visible (barre de progression, caractères qui changent de couleur, stats en fin de partie) n'était pas ce qui posait problème. La complexité se cachait ailleurs : gérer un état qui change à chaque frappe clavier, calculer un WPM qui ne saute pas dans tous les sens, et écouter le clavier globalement sans tomber dans le piège des closures obsolètes (stale closures).
Le hook useTypingTest du projet Typewriter illustre ces problèmes dans environ 200 lignes. Plutôt que de le parcourir ligne par ligne, on va suivre les décisions qui structurent son code, une par une.
useReducer vs useState empilés
Regardons ce qui se passe quand l'utilisateur appuie sur une touche. L'extrait du code suivant est tiré du fichier du hook useTypingTest :
case "KEY_PRESS": {
if (state.isFinished) return state;
const now = Date.now();
const newStartTime = state.startTime ?? now;
const isCorrect = action.key === state.currentText[state.currentIndex];
const newTypedChars = [...state.typedChars];
newTypedChars[state.currentIndex] = isCorrect ? "correct" : "incorrect";
const newIndex = state.currentIndex + 1;
const finished = newIndex === state.currentText.length;
const newIncorrectKeys = isCorrect
? state.incorrectKeys
: {
...state.incorrectKeys,
[action.key]: (state.incorrectKeys[action.key] ?? 0) + 1,
};
return {
...state,
typedChars: newTypedChars,
currentIndex: newIndex,
startTime: newStartTime,
isStarted: true,
isFinished: finished,
endTime: finished ? now : null,
incorrectKeys: newIncorrectKeys,
};
}Une seule frappe touche sept champs d'état : typedChars, currentIndex, startTime, isStarted, isFinished, endTime, incorrectKeys. Avec des useState séparés, il faudrait appeler sept setters à la suite, dans le bon ordre, en espérant qu'aucun ne dépende d'un autre qui n'a pas encore été mis à jour dans ce même render.
Le vrai risque n'est pas la lisibilité, c'est la cohérence. Si isFinished passe à true avant que endTime soit posé, ou si currentIndex avance sans que typedChars suive, l'état devient incohérent l'espace d'un render. Avec useReducer, tout ça se calcule dans une seule fonction pure, à partir d'un seul état d'entrée, et repart en un seul objet. C'est une transaction : soit tout change ensemble, soit rien.
Critère important : dès qu'une action doit modifier plusieurs champs qui doivent rester synchronisés, c'est un signal pour useReducer.
Le state qui reste hors du reducer : passageIndex
Le hook n'est pourtant pas 100% reducer. Il y a bien un useState à côté :
export function useTypingTest() {
const [passageIndex, setPassageIndex] = useState(0);
const [state, dispatch] = useReducer(
reducer,
PASSAGES[0],
createInitialState
);
// ...
const nextPassage = useCallback(() => {
const newIndex = (passageIndex + 1) % PASSAGES.length;
setPassageIndex(newIndex);
dispatch({ type: "NEXT_PASSAGE", passage: PASSAGES[newIndex] });
}, [passageIndex]);Pourquoi passageIndex n'est pas dans le reducer, alors qu'il touche pourtant à currentText ? Parce qu'il ne fait pas partie du même cycle de vie. passageIndex n'a aucune raison de changer en cohérence avec typedChars ou currentIndex : c'est un pointeur externe, qui sert juste à savoir quel texte piocher dans PASSAGES. Le reducer, lui, gère l'état interne d'un essai de frappe, pas la navigation entre essais.
Le critère ici : si un morceau de state peut vivre sa vie indépendamment, sans jamais avoir besoin d'être mis à jour de façon atomique avec le reste, il n'a pas besoin d'être dans le reducer. Tout mettre dans un seul reducer géant n'est pas un gage de qualité, c'est juste un autre extrême à éviter.
useEffect et stale closures
Point de vigilance : un useEffect avec un tableau de dépendances vide qui référence pourtant du state.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === " ") e.preventDefault();
if (e.key === "Backspace") e.preventDefault();
if (e.ctrlKey || e.altKey || e.metaKey || /* ... */) {
return;
}
if (e.key === "Backspace") {
dispatch({ type: "BACKSPACE" });
return;
}
if (e.key.length === 1) {
dispatch({ type: "KEY_PRESS", key: e.key });
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);Le réflexe classique serait de se dire : "handleKeyDown referme sur state.currentText et state.currentIndex, donc avec [] on va lire des valeurs périmées à chaque frappe". Sauf que ce n'est pas le cas ici, et c'est le détail important : handleKeyDown ne lit aucun state. Il lit uniquement e.key, et il appelle dispatch.
dispatch est garanti stable par React entre les renders (l'identité de la fonction ne change jamais). Donc l'effet n'a vraiment aucune dépendance qui varie : il peut s'exécuter une seule fois au montage, poser un seul listener, et laisser le reducer s'occuper de lire le state à jour au moment où l'action arrive. C'est le reducer qui reçoit state en paramètre à chaque dispatch, pas la closure du useEffect.
Une stale closure n'est un problème que si la fonction capturée lit une valeur de state directement. Si elle se contente de dispatcher une action, le reducer prend le relais et il n'y a plus de closure à s'inquiéter.
Lisser le WPM avec l'EMA
Le calcul brut du WPM (Words Per Minute) : (caractères corrects / 5) / minutes écoulées est extrêmement instable seconde par seconde, surtout en début de frappe où l'échantillon est minuscule. Le hook lisse ça avec une moyenne mobile exponentielle, calculée dans le TICK :
case "TICK": {
if (!state.startTime || state.isFinished) return state;
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const correctCount = state.typedChars.filter((s) => s === "correct").length;
const rawWpm = elapsed > 0 ? Math.round(correctCount / 5 / (elapsed / 60)) : 0;
const alpha = 0.4;
const ema =
state.lastEma === null
? rawWpm
: Math.round(alpha * rawWpm + (1 - alpha) * state.lastEma);
return {
...state,
elapsedSeconds: elapsed,
lastEma: ema,
wpmHistory: [...state.wpmHistory, { second: elapsed, wpm: ema }],
};
}L'idée d'une EMA (Exponential Moving Average): à chaque tick, la nouvelle valeur affichée est un combinaison pondérée entre le calcul brut de l'instant (rawWpm) et la valeur précédemment lissée (lastEma). Le coefficient alpha (ici 0.4) fixe le poids donné à la nouvelle mesure : plus alpha est proche de 1, plus l'affichage réagit vite mais saute davantage; plus il est proche de 0, plus c'est stable mais lent à refléter un vrai changement de rythme. 0.4 est un compromis raisonnable pour un affichage qui se met à jour chaque seconde.
Ce calcul vit dans le reducer, et pas dans un useEffect séparé ou un useMemo, parce qu'il dépend d'un état qui doit lui-même être mémorisé d'un tick à l'autre (lastEma). Ce n'est pas une valeur dérivable à la volée : elle a besoin de "se souvenir" d'elle-même. C'est justement un signal que ça doit être stocké, pas recalculé, ce qui nous amène à la section suivante.
Note : l'EMA n'est pas la seule option. Une moyenne mobile simple (SMA) ou un debounce sur l'affichage auraient aussi lissé le WPM. L'EMA a été choisie parce qu'elle ne demande qu'une seule valeur mémorisée (lastEma), pas un historique complet, cohérent avec un reducer qui reste léger.
State dérivé vs state stocké
Tout n'est pas dans le reducer. Une bonne partie des valeurs retournées par le hook sont recalculées à chaque render, directement dans le corps de la fonction :
const { correctCount, incorrectCount } = state.typedChars.reduce(
(acc, s) => {
if (s === "correct") acc.correctCount++;
else if (s === "incorrect") acc.incorrectCount++;
return acc;
},
{ correctCount: 0, incorrectCount: 0 }
);
const totalTyped = correctCount + incorrectCount;
const accuracy =
totalTyped > 0 ? Math.round((correctCount / totalTyped) * 100) : 100;
const wpm =
finalElapsed > 0 ? Math.round(correctCount / 5 / (finalElapsed / 60)) : 0;
const progress = Math.round(
(state.currentIndex / state.currentText.length) * 100
);accuracy, progress, et même le wpm final ne sont jamais stockés dans le reducer. Ils sont recalculés à chaque render à partir de typedChars et currentIndex, qui eux sont dans le state. Si une valeur peut être entièrement reconstruite à partir d'un state déjà existant, elle n'a pas besoin d'être elle-même du state. La stocker en plus créerait une source de vérité dupliquée, avec le risque classique de désynchronisation.
Un détail intéressant à souligner : ce wpm final n'utilise pas l'EMA lissée du TICK. C'est un calcul indépendant, fait sur le temps total écoulé une fois le test terminé (ou en cours). Ce n'est pas un oubli : le lissage a un sens pendant que le chiffre défile en direct à l'écran, mais une fois le test fini, on veut le vrai WPM moyen sur toute la session, et non une valeur biaisée par la dynamique des toutes dernières secondes.
Les limites assumées : Date.now() dans un reducer
Un reducer est censé être pur : mêmes entrées, même sortie, toujours. Ici, Date.now() apparaît deux fois, dans KEY_PRESS et dans TICK. Techniquement, ça viole la pureté : appeler le reducer deux fois avec le même état et la même action ne donnera pas le même résultat.
En pratique, est-ce grave ? Pour ce hook, non, et il vaut la peine de dire pourquoi. Ce projet n'a ni tests unitaires du reducer, ni replay d'actions, ni rendu côté serveur (SSR) : des cas où cette impureté poserait des problèmes. L'app tourne simplement une fois, en direct, dans le navigateur.
Une solution serait d'injecter le timestamp dans l'action ({ type: "KEY_PRESS", key, now: Date.now() }), ce qui rendrait le reducer testable sans mocker Date.now() globalement. Mais ce bénéfice reste théorique : une bonne pratique dans l'absolu, mais un investissement disproportionné pour ce contexte précis.
Pistes d'amélioration
Le hook fonctionne bien tel quel, mais quelques ajustements le rendraient plus robuste et plus facile à maintenir :
Un
Setplutôt qu'une cascade de||pour les touches ignorées dans le handler clavier :
const IGNORED_KEYS = new Set([
"Shift", "Control", "Alt", "Meta", "Tab", "Escape", "CapsLock", "Enter",
]);
if (e.ctrlKey || e.altKey || e.metaKey || IGNORED_KEYS.has(e.key)) {
return;
}Plus lisible, et une touche s'ajoute en une ligne plutôt qu'en modifiant une condition géante.
Extraire les calculs dans des fonctions utilitaires, par exemple
computeWpm,computeAccuracy, ou uncomputeDerivedStats(state)qui regroupeaccuracy,progress,correctCount, etc. Chaque calcul devient testable isolément, sans passer par tout le reducer ni justifier un hook dédié, ce sont de simples fonctions pures, pas de state ni d'effet à gérer :
function isCorrectKey(char: string, expected: string) {
return char === expected;
}
function computeWpm(correctCount: number, elapsedSeconds: number) {
return elapsedSeconds > 0
? Math.round(correctCount / 5 / (elapsedSeconds / 60))
: 0;
}
function computeAccuracy(correct: number, total: number) {
return total > 0 ? Math.round((correct / total) * 100) : 100;
}Chaque calcul devient testable isolément, sans passer par tout le reducer.
Nommer le
5magique. Le calcul du WPM repose sur la convention "un mot = 5 caractères" (lettres, espaces et ponctuation compris), une norme standard en dactylographie, mais rien dans le code ne l'explique :
const CHARS_PER_WORD = 5; // convention standard en dactylographie
const wpm = elapsed > 0
? Math.round(correctCount / CHARS_PER_WORD / (elapsed / 60))
: 0;Fusionner le traitement de
Backspace. Actuellement,preventDefault()est appelé tout en haut du handler, puis ledispatchduBACKSPACEintervient plus bas, séparé par la vérification des touches ignorées. Un seul bloc regroupant les deux à l'endroit oùBackspaceest identifié rendrait l'intention plus explicite :
if (e.key === "Backspace") {
e.preventDefault();
dispatch({ type: "BACKSPACE" });
return;
}Aucun de ces points n'est bloquant, le hook est déjà fonctionnel. Ce sont des raffinements qui gagneraient en valeur si le projet grossit ou si d'autres composants viennent réutiliser ces calculs ailleurs.
En résumé
Voici trois principes réutilisables bien au-delà d'un test de frappe :
Une action qui doit modifier plusieurs champs de façon cohérente justifie souvent un
useReducerplutôt qu'une accumulation deuseState.Un effet qui ne capture aucune donnée réactive et se contente de
dispatchn'a généralement pas de problème de stale closure, même avec un tableau de dépendances vide.Une donnée calculable à partir du state existant ne devrait pas devenir une seconde source de vérité.
Envie de tester ? Tu peux essayer le projet ici. Le code source complet est disponible sur GitHub.