Inversion de dépendances en front-end

Publié le 23 août 2023
Dimitri Dumont avatar
Dimitri Dumont
Développeur 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 Next.js :

dependency-inversion-front-end

Les avantages de l'inversion de dépendances

  1. Modularité accrue : les modules deviennent plus indépendants et peuvent être réutilisés dans différents contextes.
  2. Facilité de test : le code métier ne dépend plus des dépendances externes, nous pouvons simuler rapidement et facilement ces dépendances.
  3. Facilité de maintenance : les changements dans une dépendance sont limités à un seul endroit, ce qui réduit les risques d'effets domino.
  4. 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 dans un projet basé sur React et Next.js v14. .

Consulter le code
Le code de cet article est disponible, vous pouvez le consulter sur GitHub.

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 front-end :

src/app/login/page.tsx
1"use client"
2
3import { FC, useState } from "react"
4
5const LoginPage: FC = () => {
6 const [email, setEmail] = useState("")
7 const [password, setPassword] = useState("")
8
9 const handleLogin = () => {
10 fetch("https://api.com/login", {
11 method: "POST",
12 headers: {
13 "Content-Type": "application/json",
14 },
15 body: JSON.stringify({ email, password }),
16 })
17 .then((response) => {
18 if (!response.ok) {
19 throw new Error("Login failed")
20 }
21 const user = response.json()
22 console.log("Login successful! User:", user)
23 })
24 .catch((error) => {
25 console.error("Login error:", error)
26 throw error
27 })
28 }
29
30 return (
31 <div>
32 <h2>Login Page</h2>
33 <input
34 type="email"
35 placeholder="Email"
36 value={email}
37 onChange={(e) => setEmail(e.target.value)}
38 />
39 <input
40 type="password"
41 placeholder="Password"
42 value={password}
43 onChange={(e) => setPassword(e.target.value)}
44 />
45 <button onClick={handleLogin}>Login</button>
46 </div>
47 )
48}
49
50export default LoginPage
51

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é à 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 :

src/modules/auth/auth.service.ts
1export type AuthService = {
2 login(email: string, password: string): Promise<unknown>
3 logout(): Promise<void>
4}

Ensuite, nous pouvons implémenter cette interface pour réaliser notre appel API :

src/modules/auth/auth.api.ts
1import { AuthService } from "@/modules/auth/auth.service"
2
3export const AuthApi: AuthService = {
4 login: async (email, password) => {
5 try {
6 const response = await fetch("https://api.com/login", {
7 method: "POST",
8 headers: {
9 "Content-Type": "application/json",
10 },
11 body: JSON.stringify({ email, password }),
12 })
13 if (!response.ok) {
14 throw new Error("Login failed")
15 }
16 return await response.json()
17 } catch (error) {
18 console.error("Login error:", error)
19 throw error
20 }
21 },
22 logout: () => {
23 return fetch("https://api.com/logout", {
24 method: "POST",
25 })
26 .then((response) => {
27 if (!response.ok) {
28 throw new Error("Logout failed")
29 }
30 })
31 .catch((error) => {
32 console.error("Logout error:", error)
33 throw error
34 })
35 },
36}
37

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 :

src/app/login/page.tsx
1"use client"
2
3import { FC, useState } from "react"
4import { AuthApi } from "@/modules/auth/auth.api"
5
6const authService = AuthApi
7
8const LoginPage: FC = () => {
9 const [email, setEmail] = useState("")
10 const [password, setPassword] = useState("")
11
12 const handleLogin = async () => {
13 try {
14 const user = await authService.login(email, password)
15 console.log("Login successful! User:", user)
16 } catch (error) {
17 console.error("Login error:", error)
18 }
19 }
20
21 return (
22 <div>
23 <h2>Login Page</h2>
24 <input
25 type="email"
26 placeholder="Email"
27 value={email}
28 onChange={(e) => setEmail(e.target.value)}
29 />
30 <input
31 type="password"
32 placeholder="Password"
33 value={password}
34 onChange={(e) => setPassword(e.target.value)}
35 />
36 <button onClick={handleLogin}>Login</button>
37 </div>
38 )
39}
40
41export default LoginPage
42

Pour simplifier votre code, vous pouvez créer un type qui regroupe toutes les interfaces :

src/dependencies.ts
1import { AuthService } from "@/modules/auth/auth.service"
2
3export type Dependencies = {
4 authService: AuthService
5}

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 :

src/dependencies.ts
1import { AuthService } from "@/modules/auth/auth.service"
2import { AuthApi } from "@/modules/auth/auth.api"
3
4export type Dependencies = {
5 authService: AuthService
6}
7
8export const dependencies: Dependencies = {
9 authService: AuthApi,
10}
11

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 :

Hexa web
Besoin d’une équipe de développeurs front-end ?

Appeler directement vos implémentations des dépendances dans votre code, par exemple dans un composant React

src/app/login/page.tsx
1"use client"
2
3import { FC, useState } from "react"
4import { dependencies } from "@/dependencies"
5
6const LoginPage: FC = () => {
7 const [email, setEmail] = useState("")
8 const [password, setPassword] = useState("")
9
10 const handleLogin = async () => {
11 try {
12 const user = await dependencies.authService.login(email, password)
13 console.log("Login successful! User:", user)
14 } catch (error) {
15 console.error("Login error:", error)
16 }
17 }
18
19 return (
20 <div>
21 <h2>Login Page</h2>
22 <input
23 type="email"
24 placeholder="Email"
25 value={email}
26 onChange={(e) => setEmail(e.target.value)}
27 />
28 <input
29 type="password"
30 placeholder="Password"
31 value={password}
32 onChange={(e) => setPassword(e.target.value)}
33 />
34 <button onClick={handleLogin}>Login</button>
35 </div>
36 )
37}
38
39export default LoginPage
40

Créer un contexte et un provider pour permettre à tous les composants d’accéder aux dépendances

1. Création du contexte

src/dependencies.context.ts
1import { createContext, useContext } from "react"
2import { Dependencies } from "@/dependencies"
3
4export const DependenciesContext = createContext<Dependencies | null>(null)
5
6export const useDependencies = () => {
7 const context = useContext(DependenciesContext)
8 if (!context) {
9 throw new Error(
10 "useDependencies must be used within a DependencyContextProvider",
11 )
12 }
13 return context
14}
15

2. Implémentation du contexte dans l'application

src/app/layout.tsx
1import "./globals.css"
2import type { Metadata } from "next"
3import { Inter } from "next/font/google"
4import { DependenciesContext } from "@/dependencies.context"
5import { dependencies } from "@/dependencies"
6
7const inter = Inter({ subsets: ["latin"] })
8
9export const metadata: Metadata = {
10 title: "Create Next App",
11 description: "Generated by create next app",
12}
13
14export default function RootLayout({
15 children,
16}: {
17 children: React.ReactNode
18}) {
19 return (
20 <html lang="en">
21 <body className={inter.className}>
22 <DependenciesContext.Provider value={dependencies}>
23 {children}
24 </DependenciesContext.Provider>
25 </body>
26 </html>
27 )
28}
29

3. Utilisation du contexte dans vos composants pour accéder aux dépendances

src/app/login/page.tsx
1"use client"
2
3import { FC, useState } from "react"
4import { useDependencies } from "@/dependencies.context"
5
6const LoginPage: FC = () => {
7 const { authService } = useDependencies()
8
9 const [email, setEmail] = useState("")
10 const [password, setPassword] = useState("")
11
12 const handleLogin = async () => {
13 try {
14 const user = await authService.login(email, password)
15 console.log("Login successful! User:", user)
16 } catch (error) {
17 console.error("Login error:", error)
18 }
19 }
20
21 return (
22 <div>
23 <h2>Login Page</h2>
24 <input
25 type="email"
26 placeholder="Email"
27 value={email}
28 onChange={(e) => setEmail(e.target.value)}
29 />
30 <input
31 type="password"
32 placeholder="Password"
33 value={password}
34 onChange={(e) => setPassword(e.target.value)}
35 />
36 <button onClick={handleLogin}>Login</button>
37 </div>
38 )
39}
40
41export default LoginPage
42

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 :

src/module/store.ts
1import { dependencies } from '@/dependencies.ts'
2
3const store = configureStore({
4 reducer: rootReducer,
5 middleware: getDefaultMiddleware =>
6 getDefaultMiddleware({
7 thunk: {
8 extraArgument: dependencies
9 }
10 })
11})

Ainsi, vous pouvez accéder aux dépendances injectées depuis vos thunks :

src/module/auth/login.thunk.ts
1export const login =
2 (username, password) => async (dispatch, getState, { authService }) => {
3 const response = await authService.login(username, password)
4 dispatch(login(response))
5 }

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.