S'il y a un pattern que j'impose dans chaque équipe que je dirige, c'est la séparation entre smart et dumb components. Ça semble simple. En pratique, c'est la décision architecturale la plus impactante qu'on puisse prendre dans une codebase frontend — et les équipes qui l'ignorent en paient le prix à chaque sprint.
L'idée n'est pas nouvelle, mais je constate qu'elle est systématiquement mal comprise. Il ne s'agit pas seulement de « composants avec état » versus « composants sans état ». Il s'agit d'une séparation fondamentale des responsabilités qui détermine à quel point votre code est réutilisable, testable et maintenable.
Qu'est-ce qu'un Dumb Component ?
Un dumb component est générique et complètement agnostique à votre logique métier. Il ne sait pas d'où viennent ses données. Il ne récupère rien. Il ne sait pas ce qu'est un code postal, ce que fait un panier, ou quelle API votre backend expose. Il reçoit des données via les props, affiche l'UI et renvoie les interactions utilisateur via des callbacks. C'est tout.
Voyez-le comme une fonction pure pour l'UI : même entrée, même sortie, à chaque fois. Un composant SelectableList ne se soucie pas de savoir s'il affiche des codes postaux, des catégories de produits ou des noms de pays. Son interface est définie en termes de concepts génériques — des items avec des labels et des values, un callback de sélection, peut-être un texte placeholder.
Nuance importante : les dumb components peuvent contenir d'autres dumb components. Une PostalCodeList peut être un dumb component qui agrège une SelectableList avec de l'UI supplémentaire — un label, un drapeau de pays, du layout. Elle ne transforme pas les données et ne mappe pas les modèles. Elle compose uniquement. Le smart component au-dessus est celui qui récupère les données brutes de l'API et les mappe dans la forme que le dumb attend. C'est la séparation : le smart transforme le modèle, le dumb agrège la présentation.
C'est le point critique : le dumb component définit sa propre interface. Il est le fournisseur. Il dit « donnez-moi des items de cette forme, et je les afficherai. » Le parent doit adapter ses données pour correspondre, pas l'inverse.
// Dumb component: generic, reusable, knows nothing about postal codes
interface SelectableListProps {
items: { label: string; value: string }[];
onSelect: (value: string) => void;
placeholder?: string;
}
export const SelectableList: FC<SelectableListProps> = ({
items,
onSelect,
placeholder = 'Select...',
}) => (
<ul role="listbox" aria-label={placeholder}>
{items.map((item) => (
<li key={item.value} role="option" onClick={() => onSelect(item.value)}>
{item.label}
</li>
))}
</ul>
);
// Also dumb! Aggregates SelectableList with postal-code-specific UI
// Adds a label and flag — but does NOT transform data, only composes
export interface PostalCodeListProps {
items: SelectableListProps['items'];
onSelect: (value: string) => void;
label?: string;
countryFlag?: string;
}
export const PostalCodeList: FC<PostalCodeListProps> = ({
items,
onSelect,
label = 'Postal code',
countryFlag,
}) => (
<div>
<span>{countryFlag} {label}</span>
<SelectableList items={items} onSelect={onSelect} placeholder={label} />
</div>
);Qu'est-ce qu'un Smart Component ?
Un smart component est le consommateur. Il connaît votre domaine métier. Il récupère des données depuis les APIs, gère l'état, traite les effets de bord et transforme les données brutes dans la forme que les dumb components attendent. C'est la colle entre votre backend et votre UI.
Un PostalCodePicker est un smart component. Il sait que les codes postaux existent, qu'ils viennent d'une API, et qu'ils sont filtrés par pays. Il récupère les données brutes et les mappe dans le format générique d'items que le dumb PostalCodeList attend. Le dumb component ne voit jamais votre modèle d'API — il ne voit que des props propres et pré-transformées. C'est pourquoi le dumb reste réutilisable : il n'a aucune idée d'où viennent les données ni quelle forme elles avaient avant.
// Smart component: fetches data AND transforms the model for the dumb component
import { PostalCodeList } from './PostalCodeList';
export const PostalCodePicker: FC<{ country: string }> = ({ country }) => {
const [codes, setCodes] = useState<PostalCode[]>([]);
useEffect(() => {
fetchPostalCodes(country).then(setCodes);
}, [country]);
// Smart transforms raw API data → shape the dumb component expects
const items = codes.map((c) => ({
label: `${c.code} — ${c.city}`,
value: c.code,
}));
return (
<PostalCodeList
items={items}
onSelect={(code) => updateAddress({ postalCode: code })}
countryFlag={getFlagEmoji(country)}
/>
);
};L'analogie du fournisseur
J'enseigne cela comme le pattern fournisseur/consommateur. Le composant enfant est le fournisseur — il fournit une interface propre et bien définie. Le composant parent est le consommateur — il consomme cette interface en transformant ses données pour qu'elles correspondent.
Cela inverse la façon dont la plupart des développeurs pensent les relations entre composants. L'instinct naturel est « le parent décide ce que l'enfant reçoit. » Mais dans un code bien architecturé, l'enfant déclare ce dont il a besoin, et le parent est responsable de l'adaptation. Le parent transforme les données du modèle vers l'interface de props de l'enfant. Le parent est responsable de la mise en page — margin, gap, grid, flex.
Pourquoi c'est important ? Parce que quand l'enfant définit le contrat, l'enfant reste générique. Il peut être réutilisé partout. Mais quand le parent dicte la forme, l'enfant devient couplé à un cas d'usage spécifique.
// The child (dumb) defines the interface — it is the PROVIDER
// The parent (smart) adapts data to that interface — it is the CONSUMER
// ❌ Wrong: dumb component receives raw API data and maps it internally
<PostalCodeList codes={rawApiResponse} format="CH" />
// Now PostalCodeList has to know how to transform your API model — it's coupled
// ✅ Right: smart transforms, dumb aggregates
// Smart maps the model to the shape the dumb expects
const items = apiResponse.map((c) => ({ label: `${c.code} — ${c.city}`, value: c.code }));
// Dumb just composes other dumb components (label + SelectableList)
<PostalCodeList items={items} onSelect={handleSelect} countryFlag="🇨🇭" />Pourquoi cette séparation change tout
Storybook et documentation : les dumb components vivent parfaitement dans Storybook. Vous passez des props, vous voyez le résultat. Pas d'APIs mockées, pas de wrappers de providers, pas de contexte d'authentification. Cela signifie que les designers peuvent revoir les composants en isolation, la QA peut tester les cas limites sans configurer des environnements complets, et les nouveaux développeurs peuvent parcourir votre bibliothèque de composants et comprendre ce qui est disponible.
Code partagé entre projets : les dumb components sont portables par définition. Cette SelectableList fonctionne dans votre checkout e-commerce, votre dashboard admin et votre app mobile. Parce qu'elle ne connaît rien de votre business, elle appartient à tous. Nous les mettons dans une bibliothèque de composants partagée et ils deviennent de véritables actifs réutilisables plutôt que des templates copiés-collés.
Le testing est simple : tester un dumb component est trivial — le rendre avec des props, vérifier la sortie. Pas de mocking d'API, pas d'état asynchrone à attendre, pas de context providers à wrapper. Tester un smart component est aussi plus propre parce que vous ne testez que la logique de données et l'orchestration, pas les détails de rendu.
Refactoring sans peur : quand la forme de votre API change, vous mettez à jour la logique de transformation du smart component. Le dumb component ne change pas. Quand votre équipe design met à jour le style des listes, vous mettez à jour le dumb component. Le smart component ne change pas. Les changements sont isolés parce que les responsabilités sont isolées.
Quand séparer
On ne commence pas séparé. On commence avec tout dans un composant. C'est important — la séparation prématurée est juste de l'abstraction prématurée déguisée. Cela rejoint directement le principe YAGNI dont j'ai parlé dans Pourquoi YAGNI bat DRY. N'extrayez pas un dumb component parce que vous pensez le réutiliser un jour. Extrayez-le quand vous en avez réellement besoin.
Je sépare quand l'un de ces critères devient vrai : le composant récupère des données ET affiche une UI complexe, la partie visuelle pourrait être réutilisée dans un autre contexte avec des données différentes, le fichier devient trop gros (ce qui signale souvent une responsabilité unique brisée), ou je veux ce composant dans Storybook.
Le HTML purement structurel — un div wrapper pour le layout, une balise section pour la sémantique — n'a pas besoin de son propre composant. N'extrayez pas les choses simplement parce que c'est du HTML. Extrayez les choses parce qu'elles représentent un élément d'UI significatif et réutilisable avec une interface claire.
Le vrai bénéfice
Les équipes qui appliquent cette séparation livrent plus vite au fil du temps. Pas au jour un — au jour un, tout écrire inline est plus rapide. Mais au bout de trois mois, l'équipe avec une séparation propre a une bibliothèque de dumb components testés et documentés qu'elle peut composer en nouvelles fonctionnalités en heures au lieu de jours. L'équipe sans a un tas de smart components enchevêtrés où chaque changement risque de casser quelque chose sans rapport.
Il ne s'agit pas de pureté architecturale. Il s'agit de rendre la deuxième fonctionnalité moins chère que la première. C'est tout l'intérêt d'une bonne architecture — réduire le coût du changement. Et aucun pattern ne le fait plus fiablement que de garder votre logique métier hors de vos composants UI.

