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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
import { AuthService } from "../auth.service.ts" const LoginPage: React.FC<{ authService: AuthService }> = ({ authService }) => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const handleLogin = () => { fetch("https://api.com/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password }), }) .then(response => { if (!response.ok) { throw new Error("Login failed"); } const user = response.json() console.log('Login successful! User:', user) }) .catch(error => { console.error("Login error:", error); throw error }) } return ( <div> <h2>Login Page</h2> <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <button onClick={handleLogin}>Login</button> </div> ) } export default LoginPage
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 :
1 2 3 4
export type AuthService = { login(email: string, password: string): Promise<User> logout(): Promise<void> }
Ensuite, nous pouvons implémenter cette interface pour réaliser notre appel API :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
import { AuthService } from "../auth.service.ts" export const AuthApi: AuthService = { login: (email, password) => { return fetch("https://api.com/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password }), }) .then(response => { if (!response.ok) { throw new Error("Login failed"); } return response.json() }) .catch(error => { console.error("Login error:", error); throw error }) }, logout: () => { return fetch("https://api.com/logout", { method: "POST", }) .then(response => { if (!response.ok) { throw new Error("Logout failed") } }) .catch(error => { console.error("Logout error:", error) throw error }) } }
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
import { AuthService } from "../auth.service.ts" const LoginPage: React.FC<{ authService: AuthService }> = ({ authService }) => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const handleLogin = async () => { try { const user = await authService.login(email, password); console.log('Login successful! User:', user) } catch (error) { console.error('Login error:', error) } } return ( <div> <h2>Login Page</h2> <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <button onClick={handleLogin}>Login</button> </div> ) } export default LoginPage
Pour simplifier votre code, vous pouvez créer un type qui regroupe toutes les interfaces :
1 2 3 4 5
import { AuthService } from "../auth.service.ts" export type Dependencies = { authService: AuthService }
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 :
1 2 3 4 5 6
import { Dependencies } from "../dependencies.ts" import { AuthApi } from "../auth.api.ts" export const dependencies: Dependencies = { authService: AuthApi }
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
import { dependencies } from "../dependencies.ts" const LoginPage = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const handleLogin = async () => { try { const user = await dependencies.authService.login(email, password); console.log('Login successful! User:', user) } catch (error) { console.error('Login error:', error) } } return ( <div> <h2>Login Page</h2> <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <button onClick={handleLogin}>Login</button> </div> ) } export default LoginPage
Créer un contexte et un provider pour permettre à tous les composants d’accéder aux dépendances
- Création du contexte React
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// dependencies.context.ts import React, { createContext, useContext } from 'react' import { AuthService } from "../auth.service.ts" // Créez le contexte des dépendances const DependenciesContext = createContext<Dependencies | null>(null) // Créez un hook pour utiliser les dépendances dans vos composants export const useDependencies = () => { const context = useContext(DependencyContext) if (!context) { throw new Error('useDependencies must be used within a DependencyContextProvider'); } return context } // Composant Provider pour envelopper votre application avec les dépendances export const DependenciesContextProvider: React.FC<Dependencies> = ({ children, ...dependencies }) => { return <DependencyContext.Provider value={dependencies}>{children}</DependencyContext.Provider> }
- Implémentation du contexte dans l'application
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// _app.ts import React from 'react' import { DependenciesContextProvider } from '../dependencies.context.ts' import { dependencies } from '../dependencies.ts' const MyApp = ({ Component, pageProps }) => { return ( <DependencyContextProvider {...dependencies}> <Component {...pageProps} /> </DependencyContextProvider> ) } export default MyApp
- Utilisation du contexte dans vos composants pour accéder aux dépendances
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// pages/login.ts import React, { useState } from 'react' import { useDependencies } from '../DependenciesContext' const LoginPage = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const { authService } = useDependencies() const handleLogin = async () => { try { const user = await authService.login(email, password) console.log('Login successful! User:', user) } catch (error) { console.error('Login error:', error) } }; return ( <div> <h2>Login Page</h2> <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <button onClick={handleLogin}>Login</button> </div> ) } export default LoginPage
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 2 3 4 5 6 7 8 9 10 11 12
// store.ts import { dependencies } from '../dependencies.ts' const store = configureStore({ reducer: rootReducer, middleware: getDefaultMiddleware => getDefaultMiddleware({ thunk: { extraArgument: dependencies } }) })
Ainsi, vous pouvez accéder aux dépendances injectées depuis vos thunks :
1 2 3 4 5 6
// login.thunk.ts export const login = (username, password) => async (dispatch, getState, { authService }) => { const response = await authService.login(username, password) dispatch(login(response)) }
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.