Dependency inversion in front-end

Dependency inversion and dependency injection are fundamental concepts in software development that enhance the modularity, maintainability, and testability of applications. While often associated with back-end projects, these principles hold significant importance in the context of front-end projects as well.

In this article, we will explore what dependency inversion and dependency injection are, and how to effectively apply them in your front-end projects using TypeScript, React.js, and Next.js.

If you're interested in delving deeper into these concepts, you can read the article I've written about Hexagonal Architecture in the front-end (also known as clean architecture) by clicking on this link: Read the article on Hexagonal Architecture in Front-End.

Understanding dependency inversion

Dependency inversion is a key principle of software development that involves reversing the flow of control within an application. Instead of a structure where low-level modules depend on high-level modules, dependency inversion advocates that low-level modules are independent and dependencies are provided externally.

In the front-end context, this means that components should not directly depend on external services, but rather depend on interfaces (or types) that we define as needed. This improves application flexibility and facilitates testing by allowing real dependencies to be easily substituted with simulated dependencies during testing.

This diagram illustrates the words of this article as well as the principle of dependency inversion in front-end with React or Nextjs: Dependency inversion in front-end diagram

The benefits of dependency inversion

  1. Increased modularity: Modules become more independent and can be reused in different contexts.
  2. Ease of testing: Business code no longer depends on external dependencies, we can quickly and easily simulate these dependencies.
  3. Ease of maintenance: Changes in an addiction are confined to one place, reducing the chances of domino effects.
  4. Reduced coupling: Dependencies are not directly integrated into the code, which reduces the coupling between the different parts of the application.

Apply dependency inversion with TypeScript, React.js and Next.js

Now that we have seen the theoretical part of front-end dependency inversion and injection, we will see how to apply it in a front-end project using TypeScript, React.js and Next.js.

TypeScript offers strong typing which can be used to define clear and precise interfaces for dependencies. You can use interfaces or types. Defining interfaces for dependencies makes it easier to substitute real dependencies with simulated dependencies during testing.

For the example project, we will develop a feature that allows you to connect.

Example without dependency inversion

In order to understand why dependency inversion is important on the front-end, here is an example of code that you can find in most projects:

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

In this example, the HTTP request to the API that allows the user to log in is directly in a React component. The problem with this practice is that we cannot easily test this component. Indeed, we cannot simulate the HTTP request to test the behavior of the component in different scenarios (for example, if the request fails).

Likewise, our React component, i.e. our user interface, is directly tied to the API request. If we want to change the API to connect, we need to modify our React component. This can be problematic if we have multiple components that depend on this API.

There is also another problem, if we want to add a new feature which requires login, we have to rewrite the login code in each component. This can quickly become problematic if we have several components that require connection.

Finally, our UI (the React components) contains business logic, which is not a good practice. This is because our React component should only focus on the UI and should not contain business logic. This makes our component harder to maintain and test.

Example with dependency inversion

To avoid all these problems, we can implement dependency inversion. To start, we'll create an interface for the authentication service:

1 2 3 4 export type AuthService = { login(email: string, password: string): Promise<User> logout(): Promise<void> }

Then we can implement this interface to make our API call:

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 }) } }

So, to make the API call, just use the interface in your code. This prevents your components from depending on external libraries or services. Here is an example of a React component that uses the interface defined above for dependency inversion:

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

To simplify your code, you can create a type that groups all the interfaces:

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

Then, depending on the context of your code (if you are in the client part, in the server part or in the tests, etc.) you can create an object that groups all the implementations of your interfaces:

1 2 3 4 5 6 import { Dependencies } from "../dependencies.ts" import { AuthApi } from "../auth.api.ts" export const dependencies: Dependencies = { authService: AuthApi }

Thus, all you have to do is call your dependencies using this object in the context you want. As part of a front-end React project, you have several possible solutions:

Call your dependencies implementations directly in your code, for example in a React component

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

Create a context and a provider to allow all components to access dependencies

  1. React context creation
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" // Create the dependency context const DependenciesContext = createContext<Dependencies | null>(null) // Create a hook to use dependencies in your components export const useDependencies = () => { const context = useContext(DependencyContext) if (!context) { throw new Error('useDependencies must be used within a DependencyContextProvider'); } return context } // Provider component to wrap your application with dependencies export const DependenciesContextProvider: React.FC<Dependencies> = ({ children, ...dependencies }) => { return <DependencyContext.Provider value={dependencies}>{children}</DependencyContext.Provider> }
  1. Context implementation in the 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
  1. Using context in your components to access dependencies
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

If you are using redux in your React project, you can use middlewares like redux thunk to inject your dependencies

With redux toolkit and redux thunk, you have to use the getDefaultMiddleware function and use the extraArgument key of the thunk object to pass the dependencies to the 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 } }) })

Thus, you can access the injected dependencies from your 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

Inversion and dependency injection are practices that bring many benefits to development projects. Applying these principles using TypeScript, React.js, and Next.js in front-end projects allows you to build applications that are modular, easily testable, and scalable.

By reducing coupling with external libraries and services, these approaches improve code quality and facilitate long-term maintenance. These practices are also at the heart of French architecture (clean architecture). If you want to know more about it, I invite you to read the article I wrote about it by clicking on this link: see the article on the front-end hexagonal architecture.

Who am I?

In addition to having supported more than forty clients as a front-end developer and being interested in code quality, architecture and front-end technologies, I am passionate about entrepreneurship and investments. This is why I decided to create this blog.