Principes SOLID en React et Next.js : guide pratique
Publié le
Développeur React & Next.js freelance
Les projets React et Next.js grossissent : composants difficiles à maintenir, tests complexes, changements qui cassent l'existant. Les principes SOLID, formulés initialement pour la programmation orientée objet, s'adaptent aux apps React et Next.js et résolvent ces problèmes.
Dans cet article :
- Tableau récapitulatif : quel principe pour quel problème
- Chaque principe illustré avec un exemple concret (avant/après)
- Le bon critère pour décider quand appliquer un pattern
- FAQ et liens vers les articles détaillés
Quel principe pour quel problème ?
| Problème courant | Symptôme | Principe |
|---|---|---|
| Fonctions qui font tout | Difficile à comprendre, modifier | SRP |
| Chaque feature casse l'existant | Régressions fréquentes | OCP |
| Bugs après ajout d'un variant | TypeScript ne signale rien | LSP |
| Composant impossible à réutiliser | Props trop spécifiques | ISP |
| Logique métier couplée à une librairie | Migration coûteuse | DIP |
SRP : la fonction fourre-tout
Single Responsibility Principle : un module a une seule raison de changer.
Une fonction qui gère plusieurs responsabilités (validation, formatage, persistance) devient difficile à maintenir. Modifier la validation risque de casser le formatage. Impossible de réutiliser une partie sans embarquer le reste.
// ❌ Une fonction, plusieurs responsabilités
const processOrder = (order: Order): string => {
// Validation
if (!order.items.length) throw new Error('Panier vide')
if (!order.customer.email) throw new Error('Email requis')
// Calcul du total
const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const tax = subtotal * 0.2
const total = subtotal + tax
// Formatage pour affichage
return `Commande: ${total.toFixed(2)}€ TTC`
}// ✅ Une responsabilité par fonction
const validateOrder = (order: Order): string[] => {
const errors: string[] = []
if (!order.items.length) errors.push('Panier vide')
if (!order.customer.email) errors.push('Email requis')
return errors
}
const calculateOrderTotal = (items: OrderItem[]): { subtotal: number; tax: number; total: number } => {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const tax = subtotal * 0.2
return { subtotal, tax, total: subtotal + tax }
}
const formatPrice = (amount: number): string => `${amount.toFixed(2)}€`Chaque fonction a une seule raison de changer. validateOrder est réutilisable côté client et serveur. calculateOrderTotal peut servir pour l'affichage panier, la confirmation, les emails. Modifier le taux de TVA n'impacte qu'une fonction.
OCP : le switch qui grandit
Open/Closed Principle : ouvert à l'extension, fermé à la modification.
Chaque nouveau type nécessite de modifier une fonction existante. Risque de régression à chaque ajout.
// ❌ Modifier la fonction à chaque type
const getStatusStyle = (type: string) => {
switch (type) {
case 'success':
return 'bg-green-500'
case 'error':
return 'bg-red-500'
// ... à modifier pour chaque nouveau type
default:
return 'bg-gray-500'
}
}
// ✅ Ajouter une entrée sans toucher au code existant
const statusStyles: Record<string, string> = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
}
const getStatusStyle = (type: string) => statusStyles[type] ?? 'bg-gray-500'Ajouter un statut revient à ajouter une entrée dans l'objet, sans modifier la fonction. Les tests existants restent valides.
Pour aller plus loin : Object literals pour les conditions
LSP : les bugs silencieux
Liskov Substitution Principle : un type dérivé est substituable à son type de base.
En TypeScript, le LSP se traduit différemment qu'en POO classique : les discriminated unions garantissent que chaque variante est correctement gérée, avec vérification au build.
type State =
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; message: string }
const renderState = (state: State): JSX.Element => {
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
return <Content data={state.data} />
case 'error':
return <Error message={state.message} />
}
// Si un cas manque, TypeScript refuse de compiler :
// la fonction pourrait retourner undefined, incompatible avec JSX.Element
}Le type de retour explicite (JSX.Element) garantit l'exhaustivité : si on ajoute un statut au type State, TypeScript signale que la fonction pourrait retourner undefined, ce qui viole le type déclaré. Avec un default, le nouveau statut tombe silencieusement dedans et le bug arrive en production.
Pour aller plus loin : Discriminated union types
ISP : le composant impossible à réutiliser
Interface Segregation Principle : ne pas dépendre d'éléments non utilisés.
Un composant reçoit un objet User complet alors qu'il n'utilise que deux propriétés. Impossible à réutiliser dans un contexte où User n'existe pas.
// ❌ Dépend de tout User
type AvatarProps = {
user: User
}
const Avatar = ({ user }: AvatarProps) => (
<img src={user.avatar} alt={user.name} />
)
// ✅ Props ciblées
type AvatarProps = {
src: string
alt: string
}
const Avatar = ({ src, alt }: AvatarProps) => (
<img src={src} alt={alt} className="size-10 rounded-full object-cover" />
)Ce composant générique est réutilisable partout. Un UserAvatar spécifique reste pertinent s'il encapsule une logique métier (fallback sur initiales, badge de statut en ligne). Le principe : ne pas dépendre de propriétés qu'on n'utilise pas.
DIP : la logique métier collée à une librairie
Dependency Inversion Principle : dépendre d'abstractions, pas d'implémentations.
Le code appelle directement Supabase, Firebase ou Stripe. Changer de service nécessite de modifier des dizaines de fichiers. Le couplage est maximal.
// ❌ Couplage direct à Supabase
import { supabase } from '@/lib/supabase'
const getUser = async (id: string) => {
const { data } = await supabase.from('users').select('*').eq('id', id).single()
return data
}
const updateUser = async (id: string, name: string) => {
await supabase.from('users').update({ name }).eq('id', id)
}// ✅ Dépendance vers une abstraction
type UserRepository = {
getById(id: string): Promise<User | null>
update(id: string, data: Partial<User>): Promise<void>
}
// Implémentation Supabase
const supabaseUserRepository: UserRepository = {
getById: async (id) => {
const { data } = await supabase.from('users').select('*').eq('id', id).single()
return data
},
update: async (id, data) => {
await supabase.from('users').update(data).eq('id', id)
},
}
// Migration vers Prisma : seul ce fichier change
const prismaUserRepository: UserRepository = {
getById: (id) => prisma.user.findUnique({ where: { id } }),
update: (id, data) => prisma.user.update({ where: { id }, data }),
}Le code métier dépend de UserRepository, pas de Supabase. Migrer vers Prisma, Drizzle ou une API REST revient à créer une nouvelle implémentation. Le reste de l'application ne change pas.
Pour aller plus loin : Inversion de dépendances en front-end
Le bon critère : résoudre un problème ou gagner du temps
Un pattern s'applique s'il résout un problème concret ou fait gagner du temps. Sinon, c'est de l'over-engineering.
DIP sur un MVP : l'inversion de dépendances peut accélérer le développement (démo sans backend, développement sans réseau). Le critère n'est pas "MVP ou pas", mais "est-ce que ça aide maintenant ?".
SRP sur une fonction simple : séparer une fonction de 10 lignes en 3 fonctions de 3 lignes n'apporte rien. Séparer une fonction de 100 lignes qui mélange validation, calcul et formatage, oui.
Patterns appliqués sans le savoir : extraire une fonction de validation, typer les props d'un composant, utiliser une discriminated union pour un état, etc. Ces réflexes appliquent déjà SRP, ISP et LSP. Les principes SOLID ne sont pas une méthodologie à adopter, mais des noms pour des pratiques souvent naturelles.
FAQ
SOLID fonctionne-t-il en programmation fonctionnelle ?
Les principes s'adaptent : SRP pour les fonctions (une fonction = une responsabilité), OCP via composition et object literals, LSP via discriminated unions, ISP via types ciblés, DIP via injection de paramètres.
SOLID ralentit-il le développement ?
Ça dépend du contexte. L'inversion de dépendances peut accélérer le développement (pas besoin d'attendre le backend). Une abstraction inutile le ralentit. Le critère : est-ce que ça résout un problème ou fait gagner du temps ?
Comment convaincre une équipe d'adopter ces pratiques ?
Démontrer sur un cas concret. Prendre une fonction ou un module problématique, appliquer un principe, montrer le bénéfice (code plus lisible, modification localisée, développement plus rapide).
Par quel principe commencer ?
SRP est le plus immédiatement applicable : extraire une fonction de validation, séparer logique et présentation. Les bénéfices sont visibles rapidement. Les autres principes s'introduisent quand le problème qu'ils résolvent apparaît.
Conclusion
Points clés
- Chaque principe résout un problème concret : appliquer uniquement quand le problème existe
- Le critère n'est pas "MVP ou projet mature", mais "est-ce que ça aide maintenant ?"
- TypeScript renforce ces principes (discriminated unions, types stricts)
- Une abstraction sans bénéfice est de l'over-engineering
Pour aller plus loin
- Inversion de dépendances en front-end
- Architecture hexagonale en front-end
- Object literals pour les conditions
- Discriminated union types
- Result Pattern vs throw
Vous souhaitez mettre en place ces bonnes pratiques dans votre équipe ? Je propose des missions de renfort d'équipe pour accompagner vos développeurs et transmettre ces compétences.

À propos de l'auteur
Je suis Dimitri Dumont, développeur freelance spécialisé React & Next.js depuis plus de 7 ans. J'ai accompagné 22 startups et réalisé 43 missions avec une note de 5/5. J'applique ces patterns au quotidien pour continuer de livrer rapidement du code évolutif. En savoir plus →
Cet article vous a été utile ?
Je peux vous accompagner sur votre projet React & Next.js.
Discutons de votre projet →