Pattern State
Permet à un objet de modifier son comportement quand son état interne change, comme s'il passait à une implémentation différente.
Le Problème
Vous développez un système de score de tennis pour Roland Garros. Le comptage au tennis est notoirement complexe : 0 → 15 → 30 → 40 → Jeu, mais à 40-40 c'est Deuce, puis Avantage, puis soit Jeu soit retour à Deuce.
L'approche naïve :
function scorePoint(server: Score, receiver: Score, whoScored: "server" | "receiver") {
if (whoScored === "server") {
if (server === "40" && receiver === "40") {
return { server: "AD", receiver: "40" };
} else if (server === "40" && receiver !== "AD") {
return { server: "Game", receiver };
} else if (server === "AD") {
return { server: "Game", receiver: "40" };
} else if (receiver === "AD") {
return { server: "40", receiver: "40" }; // back to Deuce
} else if (server === "0") {
return { server: "15", receiver };
} else if (server === "15") {
return { server: "30", receiver };
}
// ... 15 branches de plus pour toutes les combinaisons
}
// ... tout dupliquer pour le receveur
}
Des conditions imbriquées partout. Facile de rater des cas limites. Aucune garantie d'avoir couvert tous les états.
La Solution
Modélisez chaque combinaison de score comme un état avec des transitions explicites :
import { createMachine } from "@pithos/core/eidos/state/state";
// TS infère tous les états et événements — les transitions invalides sont des erreurs de compilation
const tennisGame = createMachine({
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
// ... toutes les combinaisons de score
"40-40": { p1: { to: "AD-40" }, p2: { to: "40-AD" } }, // Deuce!
"AD-40": { p1: { to: "Game-P1" }, p2: { to: "40-40" } }, // Avantage ou retour à Deuce
"40-AD": { p1: { to: "40-40" }, p2: { to: "Game-P2" } },
// Simplifié — la démo live utilise "Deuce", "AD-P1", "AD-P2" pour la lisibilité
"Game-P1": {}, // État terminal
"Game-P2": {},
}, "0-0");
tennisGame.send("p1"); // "15-0"
tennisGame.send("p1"); // "30-0"
// ... le jeu continue
Chaque transition d'état est explicite. La boucle Deuce ↔ Avantage est claire. Les états terminaux n'ont pas de transitions.
Démo
Marquez des points pour chaque joueur et observez la state machine gérer les transitions Deuce, Avantage et Jeu.
- tennisMachine.ts
- Usage
import { createMachine } from "@pithos/core/eidos/state/state";
/**
* Tennis game scoring state machine.
*
* States represent score combinations: "P1Score-P2Score"
* Events: "p1" (player 1 scores) or "p2" (player 2 scores)
*/
export type TennisState =
| "0-0" | "15-0" | "0-15" | "30-0" | "15-15" | "0-30"
| "40-0" | "30-15" | "15-30" | "0-40" | "40-15" | "30-30"
| "15-40" | "40-30" | "30-40" | "Deuce" | "AD-P1" | "AD-P2"
| "Game-P1" | "Game-P2";
export type TennisEvent = "p1" | "p2";
/** Metadata for each state — NO if/else needed */
interface StateMetadata {
p1Score: string;
p2Score: string;
phase: "normal" | "deuce" | "gameOver";
winner: "p1" | "p2" | null;
statusLabel: string | null;
}
/** All state metadata in a single record — the State pattern way */
const STATE_METADATA: Record<TennisState, StateMetadata> = {
// Normal scoring
"0-0": { p1Score: "0", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-0": { p1Score: "15", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"0-15": { p1Score: "0", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-0": { p1Score: "30", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-15": { p1Score: "15", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"0-30": { p1Score: "0", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"40-0": { p1Score: "40", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"30-15": { p1Score: "30", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"15-30": { p1Score: "15", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"0-40": { p1Score: "0", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-15": { p1Score: "40", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-30": { p1Score: "30", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"15-40": { p1Score: "15", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-30": { p1Score: "40", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"30-40": { p1Score: "30", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
// Deuce phase
"Deuce": { p1Score: "40", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Deuce" },
"AD-P1": { p1Score: "AD", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Advantage P1" },
"AD-P2": { p1Score: "40", p2Score: "AD", phase: "deuce", winner: null, statusLabel: "Advantage P2" },
// Terminal states
"Game-P1": { p1Score: "Game", p2Score: "", phase: "gameOver", winner: "p1", statusLabel: "Game" },
"Game-P2": { p1Score: "", p2Score: "Game", phase: "gameOver", winner: "p2", statusLabel: "Game" },
};
/** State transitions — the machine definition */
const TENNIS_TRANSITIONS = {
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
"15-15": { p1: { to: "30-15" }, p2: { to: "15-30" } },
"0-30": { p1: { to: "15-30" }, p2: { to: "0-40" } },
"40-0": { p1: { to: "Game-P1" }, p2: { to: "40-15" } },
"30-15": { p1: { to: "40-15" }, p2: { to: "30-30" } },
"15-30": { p1: { to: "30-30" }, p2: { to: "15-40" } },
"0-40": { p1: { to: "15-40" }, p2: { to: "Game-P2" } },
"40-15": { p1: { to: "Game-P1" }, p2: { to: "40-30" } },
"30-30": { p1: { to: "40-30" }, p2: { to: "30-40" } },
"15-40": { p1: { to: "30-40" }, p2: { to: "Game-P2" } },
"40-30": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"30-40": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Deuce and Advantage loop
"Deuce": { p1: { to: "AD-P1" }, p2: { to: "AD-P2" } },
"AD-P1": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"AD-P2": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Terminal states (no transitions)
"Game-P1": {},
"Game-P2": {},
} as const;
export function createTennisGame() {
return createMachine(TENNIS_TRANSITIONS, "0-0");
}
/** Get metadata for a state — just a lookup, no conditionals */
export function getStateMetadata(state: TennisState): StateMetadata {
return STATE_METADATA[state];
}
export interface ScoreDirections {
p1: "forward" | "backward";
p2: "forward" | "backward";
}
/** Determine flip direction per player between two states */
export function getScoreDirections(from: TennisState, to: TennisState): ScoreDirections {
const prev = STATE_METADATA[from];
const next = STATE_METADATA[to];
return {
p1: isScoreBackward(prev.p1Score, next.p1Score) ? "backward" : "forward",
p2: isScoreBackward(prev.p2Score, next.p2Score) ? "backward" : "forward",
};
}
const SCORE_ORDER = ["", "0", "15", "30", "40", "AD", "Game"];
function isScoreBackward(from: string, to: string): boolean {
const fi = SCORE_ORDER.indexOf(from);
const ti = SCORE_ORDER.indexOf(to);
if (fi === -1 || ti === -1) return false;
return ti < fi;
}
import { RotateCcw } from "lucide-react";
import { useTennisGame } from "@/hooks/useTennisGame";
import { ClayNoiseOverlay, clayCourtStyle } from "./ClayNoiseOverlay";
import { Scoreboard } from "./Scoreboard";
import { GameControls } from "./GameControls";
import { FlipDigit } from "./FlipDigit";
import { LedClock } from "./LedClock";
import { useGameClock } from "@/hooks/useGameClock";
import { TransitionHistory } from "./TransitionHistory";
const bigScoreColor = (val: string) => {
if (val === "AD") return "#f59e0b";
if (val === "—") return "#6b7280";
return "#ffffff";
};
export function TennisScoreboard() {
const { currentState, phase, winner, statusLabel, history, transition, handlePoint, handleReset } = useTennisGame();
const clock = useGameClock(history.length, phase === "gameOver");
return (
<>
{/* Mobile layout */}
<div className="flex flex-col h-screen md:hidden">
<div className="shrink-0 px-4 pt-4 pb-3" style={{ ...clayCourtStyle, background: "#c75b12" }}>
<ClayNoiseOverlay />
<div style={{ position: "relative", zIndex: 2 }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">🎾 Tennis Scoreboard</h2>
<ResetButton onClick={handleReset} />
</div>
<Scoreboard phase={phase} winner={winner} statusLabel={statusLabel} transition={transition} />
</div>
</div>
<div className="shrink-0 px-4 py-3" style={{ ...clayCourtStyle, background: "#b34e0f" }}>
<ClayNoiseOverlay />
<div className="flex flex-col gap-3" style={{ position: "relative", zIndex: 2 }}>
<p className="text-white/80 text-sm text-center">Tap to score a point<br />Watch the state machine transitions</p>
<GameControls phase={phase} onPoint={handlePoint} layout="column" />
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden px-4 py-3 bg-slate-100">
<TransitionHistory history={history} fillHeight />
</div>
</div>
{/* Desktop layout */}
<div className="hidden md:block max-w-5xl mx-auto py-8 px-4 space-y-6">
{/* Top row: scoreboard + big score — single orange block */}
<section className="rounded-xl shadow-lg overflow-hidden" style={{ ...clayCourtStyle, background: "rgba(0,0,0,0.2)" }}>
<ClayNoiseOverlay />
<div className="flex relative" style={{ zIndex: 2 }}>
{/* Left 2/3: scoreboard + controls */}
<div className="w-2/3 p-6 border-r border-white/10">
<h2 className="text-lg font-semibold text-white mb-4">🎾 Tennis Scoreboard</h2>
<Scoreboard phase={phase} winner={winner} statusLabel={statusLabel} transition={transition} />
<hr className="border-white/20 my-4" />
<p className="text-white/80 text-sm text-center mb-3">Tap to score a point · Watch the state machine transitions</p>
<div className="flex flex-col gap-3">
<GameControls phase={phase} onPoint={handlePoint} />
<ResetButton onClick={handleReset} showLabel />
</div>
</div>
{/* Right 1/3: big score */}
<div className="w-1/3 flex flex-col items-center justify-center px-10 py-6" style={{ background: "#1a3829" }}>
<div className="text-sm text-white/50 uppercase tracking-wider mb-4">Current Game</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-x-6">
<div className="w-24 h-20 overflow-hidden">
<FlipDigit
from={phase === "gameOver" ? transition.from.p1 : transition.from.p1}
to={phase === "gameOver" ? "—" : transition.to.p1}
direction={transition.directions.p1}
textClass="font-bold text-5xl tabular-nums"
colorFn={bigScoreColor}
/>
</div>
<span className="text-3xl text-white/30 font-light">–</span>
<div className="w-24 h-20 overflow-hidden">
<FlipDigit
from={phase === "gameOver" ? transition.from.p2 : transition.from.p2}
to={phase === "gameOver" ? "—" : transition.to.p2}
direction={transition.directions.p2}
textClass="font-bold text-5xl tabular-nums"
colorFn={bigScoreColor}
/>
</div>
<div className="text-sm text-white/50 text-center mt-2">Nadal</div>
<div />
<div className="text-sm text-white/50 text-center mt-2">Djokovic</div>
</div>
<hr className="border-white/20 w-full my-4" />
{/* Match clock — LED display */}
<div className="mt-6 text-center">
<LedClock value={clock} />
<div className="text-sm text-white/50 uppercase tracking-wider mt-1">Match Time</div>
</div>
</div>
</div>
</section>
{/* Bottom: State Machine (horizontal) */}
<section className="bg-white/70 rounded-xl shadow-sm border border-slate-200 p-6">
<div className="flex items-start gap-8">
<div className="shrink-0">
<h2 className="text-lg font-semibold text-slate-900 mb-2">State Machine</h2>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xs text-slate-500 mb-1">Current State</div>
<div className="text-xl font-mono font-bold" style={{ color: "#c75b12" }}>{currentState}</div>
</div>
</div>
<div className="flex-1 min-w-0">
<TransitionHistory history={history} />
</div>
</div>
</section>
{/* Key insight */}
<section className="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-5">
<p className="text-sm text-amber-800">
<strong>Key insight:</strong> No if/else, just a lookup in STATE_METADATA[currentState].
The Deuce ↔ Advantage loop is encoded in transitions, not conditionals.
</p>
</section>
</div>
</>
);
}
function ResetButton({ onClick, showLabel }: { onClick: () => void; showLabel?: boolean }) {
return (
<button
onClick={onClick}
className={`${showLabel ? "w-full py-3 px-4" : "py-2 px-3"} bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors flex items-center justify-center gap-2`}
aria-label="Reset game"
>
<RotateCcw className={showLabel ? "w-5 h-5" : "w-4 h-4"} />
{showLabel && "Reset"}
</button>
);
}
Analogie
Le tableau de score d'un arbitre de tennis. L'arbitre ne pense pas « si le joueur 1 a 40 et le joueur 2 a 30, alors... ». Il sait simplement : depuis ce score, un point pour le joueur 1 mène à tel score. Les règles sont encodées dans les transitions, pas dans de la logique conditionnelle.
Quand l'Utiliser
- Le comportement d'un objet dépend de son état
- Vous avez de la logique conditionnelle complexe basée sur l'état
- Les transitions d'état doivent être explicites et validées
- Construction de workflows, logique de jeu, ou processus multi-étapes
Quand NE PAS l'Utiliser
Si votre état est juste un flag booléen (on/off, activé/désactivé), une state machine ajoute de la cérémonie inutile. Utilisez-la quand vous avez 3+ états avec des règles de transition non triviales.
API
- createMachine — Créer une state machine finie avec des états et événements typés