Inversion de dépendances en front-end
L'inversion de dépendances et l'injection de dépendances sont des concepts fondamentaux en développement logiciel qui permettent d'améliorer la modularité, la maintenabilité et la testabilité des applications. Bien que souvent associés aux projets back-end, ces principes ont également une grande importance dans le contexte des projets front-end.
Dans cet article, nous allons découvrir ce que sont l'inversion et l'injection de dépendances et comment les appliquer efficacement dans vos projets front-end avec des exemples en utilisant TypeScript, React.js et Next.js.
Si vous souhaitez approfondir davantage ces concepts, vous pouvez lire l'article que j’ai écrit au sujet de l’architecture hexagonale en front-end (ou clean architecture) en cliquant sur ce lien : voir l'article sur l'architecture hexagonale en front-end.
Comprendre l'inversion de dépendances
L'inversion de dépendances est un principe clé du développement logiciel qui consiste à inverser le flux de contrôle au sein d'une application. Au lieu d'une structure où les modules de bas niveau dépendent des modules de haut niveau, l'inversion de dépendances préconise que les modules de bas niveau soient indépendants et que les dépendances soient fournies de l'extérieur.
Dans le contexte front-end, cela signifie que les composants ne devraient pas dépendre directement de services externes, mais plutôt de dépendre des interfaces (ou types) que nous définissons selon nos besoins. Cela améliore la flexibilité de l'application et facilite les tests en permettant de substituer facilement des dépendances réelles par des dépendances simulées lors des tests.
Ce schéma illustre les propos de cet article ainsi que le principe d'inversion de dépendances en front-end avec React ou Nextjs :
Les avantages de l'inversion de dépendances
- Modularité accrue : Les modules deviennent plus indépendants et peuvent être réutilisés dans différents contextes.
- Facilité de test : Le code métier ne dépend plus des dépendances externes, nous pouvons simuler rapidement et facilement ces dépendances.
- Facilité de maintenance : Les changements dans une dépendance sont limités à un seul endroit, ce qui réduit les risques d'effets domino.
- Couplage réduit : Les dépendances ne sont pas directement intégrées dans le code, ce qui réduit le couplage entre les différentes parties de l'application.
Appliquer l'inversion de dépendances avec TypeScript, React.js et Next.js
Maintenant que nous avons vu la partie théorique de l'inversion et de l'injection de dépendances en front-end, nous allons voir comment l'appliquer dans un projet front-end en utilisant TypeScript, React.js et Next.js.
TypeScript offre un fort typage qui peut être utilisé pour définir des interfaces claires et précises pour les dépendances. Vous pouvez utiliser des interfaces ou des types. Définir des interfaces pour les dépendances facilite la substitution de dépendances réelles par des dépendances simulées lors des tests.
Pour le projet d'exemple, nous allons développer une fonctionnalité qui permet de se connecter.
Exemple sans inversion de dépendances
Afin de comprendre pourquoi l'inversion de dépendances est importante en front-end, voici un exemple de code que vous pouvez retrouver dans la plupart des projets :
1import { AuthService } from "../auth.service.ts" 2 3const LoginPage: React.FC<{ authService: AuthService }> = ({ authService }) => { 4 const [email, setEmail] = useState('') 5 const [password, setPassword] = useState('') 6 7 const handleLogin = () => { 8 fetch("https://api.com/login", { 9 method: "POST", 10 headers: { 11 "Content-Type": "application/json", 12 }, 13 body: JSON.stringify({ email, password }), 14 }) 15 .then(response => { 16 if (!response.ok) { 17 throw new Error("Login failed"); 18 } 19 const user = response.json() 20 console.log('Login successful! User:', user) 21 }) 22 .catch(error => { 23 console.error("Login error:", error); 24 throw error 25 }) 26 } 27 28 return ( 29 <div> 30 <h2>Login Page</h2> 31 <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> 32 <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> 33 <button onClick={handleLogin}>Login</button> 34 </div> 35 ) 36} 37 38export default LoginPage 39
Dans cet exemple, la requête HTTP à l'API qui permet à l'utilisateur de se connecter se trouve directement dans un composant React. Le problème avec cette pratique, est que nous ne pouvons pas facilement tester ce composant. En effet, nous ne pouvons pas simuler la requête HTTP pour tester le comportement du composant dans différents cas de figure (par exemple, si la requête échoue).
De même, notre composant React, c'est-à-dire notre interface utilisateur, est directement liée à la requête API. Si nous souhaitons changer l'API pour se connecter, nous devons modifier notre composant React. Cela peut être problématique si nous avons plusieurs composants qui dépendent de cette API.
Il y a également un autre problème, si nous souhaitons ajouter une nouvelle fonctionnalité qui nécessite une connexion, nous devons réécrire le code de connexion dans chaque composant. Cela peut rapidement devenir problématique si nous avons plusieurs composants qui nécessitent une connexion.
Enfin, notre interface utilisateur (les composants React) contient de la logique métier, ce qui n'est pas une bonne pratique. En effet, notre composant React doit uniquement se concentrer sur l'interface utilisateur et ne doit pas contenir de la logique métier. Cela rend notre composant plus difficile à maintenir et à tester.
Exemple avec inversion de dépendances
Pour éviter tous ces problèmes, nous pouvons mettre en place l'inversion de dépendances. Pour commencer, nous allons créer une interface pour le service d'authentification :
1export type AuthService = { 2 login(email: string, password: string): Promise<User> 3 logout(): Promise<void> 4} 5
Ensuite, nous pouvons implémenter cette interface pour réaliser notre appel API :
1import { AuthService } from "../auth.service.ts" 2 3export const AuthApi: AuthService = { 4 login: (email, password) => { 5 return fetch("https://api.com/login", { 6 method: "POST", 7 headers: { 8 "Content-Type": "application/json", 9 }, 10 body: JSON.stringify({ email, password }), 11 }) 12 .then(response => { 13 if (!response.ok) { 14 throw new Error("Login failed"); 15 } 16 return response.json() 17 }) 18 .catch(error => { 19 console.error("Login error:", error); 20 throw error 21 }) 22 }, 23 logout: () => { 24 return fetch("https://api.com/logout", { 25 method: "POST", 26 }) 27 .then(response => { 28 if (!response.ok) { 29 throw new Error("Logout failed") 30 } 31 }) 32 .catch(error => { 33 console.error("Logout error:", error) 34 throw error 35 }) 36 } 37} 38
Ainsi, pour réaliser l'appel API, il suffit d’utiliser l’interface dans votre code. Cela évite à vos composants de dépendre des librairies ou des services externes. Voici un exemple de composant React qui utilise l’interface définie plus haut pour l’inversion de dépendances :
1import { AuthService } from "../auth.service.ts" 2 3const LoginPage: React.FC<{ authService: AuthService }> = ({ authService }) => { 4 const [email, setEmail] = useState('') 5 const [password, setPassword] = useState('') 6 7 const handleLogin = async () => { 8 try { 9 const user = await authService.login(email, password); 10 console.log('Login successful! User:', user) 11 } catch (error) { 12 console.error('Login error:', error) 13 } 14 } 15 16 return ( 17 <div> 18 <h2>Login Page</h2> 19 <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> 20 <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> 21 <button onClick={handleLogin}>Login</button> 22 </div> 23 ) 24} 25 26export default LoginPage 27
Pour simplifier votre code, vous pouvez créer un type qui regroupe toutes les interfaces :
1import { AuthService } from "../auth.service.ts" 2 3export type Dependencies = { 4 authService: AuthService 5} 6
Ensuite, en fonction du contexte de votre code (si vous êtes dans la partie client, dans la partie serveur ou dans les tests, etc.) vous pouvez créer un objet qui regroupe toutes les implémentations de vos interfaces :
1import { Dependencies } from "../dependencies.ts" 2import { AuthApi } from "../auth.api.ts" 3 4export const dependencies: Dependencies = { 5 authService: AuthApi 6} 7
Ainsi, il ne vous reste plus qu’à appeler vos dépendances grâce à cet objet dans le contexte que vous souhaitez. Dans le cadre d’un projet React en front-end, vous avez plusieurs solutions possibles :
Appeler directement vos implémentations des dépendances dans votre code, par exemple dans un composant React
1import { dependencies } from "../dependencies.ts" 2 3const LoginPage = () => { 4 const [email, setEmail] = useState('') 5 const [password, setPassword] = useState('') 6 7 const handleLogin = async () => { 8 try { 9 const user = await dependencies.authService.login(email, password); 10 console.log('Login successful! User:', user) 11 } catch (error) { 12 console.error('Login error:', error) 13 } 14 } 15 16 return ( 17 <div> 18 <h2>Login Page</h2> 19 <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> 20 <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> 21 <button onClick={handleLogin}>Login</button> 22 </div> 23 ) 24} 25 26export default LoginPage 27
Créer un contexte et un provider pour permettre à tous les composants d’accéder aux dépendances
- Création du contexte React
1// dependencies.context.ts 2import React, { createContext, useContext } from 'react' 3import { AuthService } from "../auth.service.ts" 4 5// Créez le contexte des dépendances 6const DependenciesContext = createContext<Dependencies | null>(null) 7 8// Créez un hook pour utiliser les dépendances dans vos composants 9export const useDependencies = () => { 10 const context = useContext(DependencyContext) 11 if (!context) { 12 throw new Error('useDependencies must be used within a DependencyContextProvider'); 13 } 14 return context 15} 16 17// Composant Provider pour envelopper votre application avec les dépendances 18export const DependenciesContextProvider: React.FC<Dependencies> = ({ children, ...dependencies }) => { 19 return <DependencyContext.Provider value={dependencies}>{children}</DependencyContext.Provider> 20} 21
- Implémentation du contexte dans l'application
1// _app.ts 2import React from 'react' 3import { DependenciesContextProvider } from '../dependencies.context.ts' 4import { dependencies } from '../dependencies.ts' 5 6const MyApp = ({ Component, pageProps }) => { 7 return ( 8 <DependencyContextProvider {...dependencies}> 9 <Component {...pageProps} /> 10 </DependencyContextProvider> 11 ) 12} 13 14export default MyApp 15
- Utilisation du contexte dans vos composants pour accéder aux dépendances
1// pages/login.ts 2import React, { useState } from 'react' 3import { useDependencies } from '../DependenciesContext' 4 5const LoginPage = () => { 6 const [email, setEmail] = useState('') 7 const [password, setPassword] = useState('') 8 9 const { authService } = useDependencies() 10 11 const handleLogin = async () => { 12 try { 13 const user = await authService.login(email, password) 14 console.log('Login successful! User:', user) 15 } catch (error) { 16 console.error('Login error:', error) 17 } 18 }; 19 20 return ( 21 <div> 22 <h2>Login Page</h2> 23 <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> 24 <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> 25 <button onClick={handleLogin}>Login</button> 26 </div> 27 ) 28} 29 30export default LoginPage 31
Si vous utilisez redux dans votre projet React, vous pouvez utiliser les middlewares comme redux thunk pour injecter vos dépendances
Avec redux toolkit et redux thunk, il faut utiliser la fonction getDefaultMiddleware et utiliser la clé extraArgument de l'objet thunk pour passer les dépendances aux thunks :
1// store.ts 2import { dependencies } from '../dependencies.ts' 3 4const store = configureStore({ 5 reducer: rootReducer, 6 middleware: getDefaultMiddleware => 7 getDefaultMiddleware({ 8 thunk: { 9 extraArgument: dependencies 10 } 11 }) 12}) 13
Ainsi, vous pouvez accéder aux dépendances injectées depuis vos thunks :
1// login.thunk.ts 2export const login = 3 (username, password) => async (dispatch, getState, { authService }) => { 4 const response = await authService.login(username, password) 5 dispatch(login(response)) 6 } 7
Conclusion
L'inversion et l'injection de dépendances sont des pratiques qui apportent de nombreux avantages aux projets de développement. En appliquant ces principes à l'aide de TypeScript, React.js et Next.js dans les projets front-end, cela vous permet de créer des applications modulaires, facilement testables et évolutives.
En réduisant le couplage avec les librairies et les services externes, ces approches améliorent la qualité du code et facilitent la maintenance à long terme. Ces pratiques sont d’ailleurs au cœur de l’architecture hexagonale (clean architecture). Si vous souhaitez en savoir plus à ce sujet, je vous invite à consulter l’article que j’ai écrit à ce sujet en cliquant sur ce lien : voir l'article sur l'architecture hexagonale en front-end.