I've been a fan of managing dependencies efficiently for years, using dependency injection. If you're not familiar with this concept, don't worry - I'll explain it shortly.
But it is definitely something I've seen more commonly used in backend applications than in React applications. In this article I'm going to explain what it is, how to use dependency injection in frontend React applications, and what the benefits are.
To kick it off I will explain what DI is in general terms, and then explain how it can be used in React applications.
What is dependency injection?
If you are familiar with DI then skip this section
On a very basic level, dependency injection (DI) is a design pattern that allows you to inject variables, objects, or services into your code rather than hard-coding or instantiating them within your code directly.
I'll show it with some simple examples (I'll get onto React examples soon):
If we had a function to save a blog post, we could write something like this:
import {getDbConnection} from './db' function saveBlogPost(title: string, body: string) { await getDbConnection().save({title, body}) }
But testing this code is challenging. We would have to mock the function getDbConnection
, which requires knowing its implementation details.
It is also less flexible - if we wanted to provide another way to save blog posts (for example, save them locally and not to the database).
With DI, you would pass in (inject in) an object, like this:
✅ copied// all injected storage services must conform to this shape: interface BlogStorage { save: (post: {title: string, body: string}) => Promise<void> } function saveBlogPost(blogStorage: BlogStorage, title: string, body: string) { await blogStorage.save({title, body}) } // example implementation class DbBlogStorage implements BlogStorage { constructor() { this.connection = // ... set up db connection } save(title: string, body: string) { await this.connection.save(title, body) } } // tying it all together: const storage: BlogStorage = new DbBlogStorage(); saveBlogPost(storage, "some blog post", "some text here");
Obviously, this example is not specific to the frontend yet. At first glance, this might seem overkill or overengineered, so I will show how DI can be beneficial in a React context.
But now it means we can swap out the storage
argument with anything else that confirms to the BlogStorage
interface. This makes testing easy. For example:
const mockBlogStorage: BlogStorage = { save: jest.fn() } saveBlogPost(mockBlogStorage, 'title', 'blog') expect(mockBlogStorage).toHaveBeenCalledTimes(1)
How can dependency injection work in frontend and React applications?
In a React context, managing dependencies can be handled using "inversion of control" through React's createContext
and useContext
.
You can use this to pass dependencies down the component tree without prop drilling, giving you a cleaner and more maintainable codebase.
It is effectively a simple way to inject variables and objects into places that need them.
Context is a core part of React. I will assume you are familiar with it, but if not check out the official docs here
React example with DI
My first example was a simple function - nothing React specific. Now I'm going to show a more realistic simple React example.
Let's pretend you have a blog post with a 'share' button. When you click the share button, it should call a function sendAnalyticsEvent('share-button-pressed')
(and then show a url to share).
✅ copiedimport { sendAnalyticsEvent } from './infrastructure/analytics'; function YourBlogPost({ blogPost }: { blogPost: BlogPost }) { const share = () => { sendAnalyticsEvent('share-button-pressed'); // << this fn is what we want to inject // then show user some url to share alert('Share this URL: http://example.com/' + blogPost.slug); }; return ( <div> <h1>{blogPost.title}</h1> <button onClick={share}>Share</button> </div> ); }
The issue with this approach is that our component is tightly coupled to our analytics function.
That function is likely making HTTP requests to send analytics data to some third party API. In our tests, we won't want that behavior to trigger real library/API calls.
If we use Storybook to preview our components in isolation, we will not want this API calling behaviour. Right now, it is quite awkward to work with because it is all hard-coded directly in the function. However, by managing dependencies like this, we could easily inject some mock analytics functions for Storybook.
Step by step of introducing inversion of control in React
We want to do a few steps:
- Create an adapter interface. Define a contract for the functionality you need (e.g. a function that can accept a string such as
share-button-pressed
). - Create a context to hold this adapter (dependency management container).
- Use that context's provider in your app to set up the concrete implementation of the adapter.
- Then in your
YourBlogPost
component, access the dependency (viauseContext()
helper hook) and use it.
If that is a bit confusing, the following examples should clear it up!
Create an adapter interface
Create a type that describes the shape of what you want to inject in.
export type SendAnalyticsEvent = (eventType: string) => void;
Create a context which accepts this shape
Next step is to create a container to hold your dependencies (and pass them down to the components which use them):
✅ copied// interface for shape of the context interface DIContainerInjectors { sendAnalyticsEvent: SendAnalyticsEvent // references the shape defined above ^ } // create the context (no initial values) const InjectionContainerContext = createContext<DIContainerInjectors>() // create a component which can provide the values export const InjectionContainerProvider = (props: {sendAnalyticsEvent: SendAnalyticsEvent}) => { const injectors: DIContainerInjectors = { sendAnalyticsEvent: props.sendAnalyticsEvent // ... and any other services you want to inject in } return <InjectionContainerContext.Provider value={injectors}> {props.children} </InjectionContainerContext.Provider> } // helper hook to get the injectors export const useInjectedValue = (): DIContainerInjectors => { const ctx = useContext(InjectionContainerContext) if(!ctx) throw new Error('Must use InjectionContainerProvider first') return ctx }
Use the context in your app
Then in your real app you would use the container provider.
In this example it directly uses YourBlogPost
as a child, but obviously it could be used in any (sub) child component.
app.tsx✅ copied// import whatever dep you want to inject in here. // this is a simplified example, in reality you might // use a more complex DI service container to manage what gets injected in // but also see the test example which shows a test of YourBlogPost with a mock analytics // dependency injected in. import {sendAnalyticsEvent} from './sendAnalyticsEvent' export function App({Component}) { const someBlogPost = { /* ... */ }; return (<InjectionContainerProvider sendAnalyticsEvent={sendAnalyticsEvent}> <YourBlogPost blogPost={someBlogPost} /> </InjectionContainerProvider>); }
Use the injected object in your component
Finally we can now use the injected object in our component:
✅ copiedimport {sendAnalyticsEvent} from './infrastructure/analytics'; function YourBlogPost({blogPost}: {blogPost: BlogPost}) { const {sendAnalyticsEvent} = useInjectedValue() const share = () => { sendAnalyticsEvent('share-button-pressed') // << this is now injected in! // we didn't import it directly // then show user some url to share alert('Share this url: http://example.com/' + blogPost.slug) } <div> <h1>{blogPost.title}</h1> <button onClick={share}>Share</button> </div> }
How to test your component
Now that you have set up your dependency management system, you can inject a mock function by using the same InjectionContainerProvider component, but with some mock functions.
✅ copiedit('should send analytics event', async () => { const mockSendAnalyticsEventFn = jest.fn(); render( <InjectionContainerProvider sendAnalyticsEvent={mockSendAnalyticsEventFn}> <YourBlogPost blogPost={{ title: 'Test Post', slug: 'test-post' }} /> </InjectionContainerProvider> ); const button = screen.getByText('Share'); await userEvent.click(button); expect(mockSendAnalyticsEventFn).toHaveBeenCalledTimes(1); });
What to inject
In a React app, your components should generally just deal with the 'view' part - how it looks and setting up event handlers (onClick
etc).
Things with side effects (e.g. making API calls, or interacting with browser web APIs such as localStorage) or business logic generally shouldn't be part of your React components and should be abstracted away.
These things are often good candidates for dependency management.
Some examples:
- analytics (like my simple example above)
- internal API calls (to your backend)
- external API calls (to third party services)
- complex/slow to compute business logic (inject in simple logic for most of your tests which don't need to test the internals of the business logic)
window.localStorage
,window.sessionStorage
etc- configuration or environment variables. You can use DI to inject in variables - not just services/functions
- logging - useful to have a injected in logger which could send the data to your logging service
What are the benefits of using dependency injection?
My example is quite small, but hopefully the basic idea makes sense. It is extra code, more boiler plate, and slightly more confusing about what code is actually being run.
You might be wondering why it is worth going to this extra effort. The main benefits are that it results in easier to maintain code, more reusable code, and much easier to test code.
As well as making testing easier (so useful for TDD), you can use tools such as Storybook to preview your individual components and easily inject in mock services/objects (potentially without doing real API calls for example).
Your code will be much less coupled, which often means easier to maintain.
It is also much easier to see a clear separation of concerns.
Difficulties with dependency injection
As I've said - I am a fan of managing dependencies efficiently. But there are some drawbacks to using dependency management techniques which you should consider before adding them to an application.
No standard way to do it in React
In most backend frameworks there tends to be a very standard and common way to do it for each different framework/library. In React we tend to just do it with manually writing out useContext()
/ createContext()
. This is fine, it works well and gives you flexibility, but I wish there was a really common library to handle it for you.
- If you are looking for a FE famework where dependency injection is common, check out Angular.
- For more general DI libraries check out:
Complexity writing & debugging
It is much easier to just hard code new SomeService()
than to define a common interface, provide the value, etc. Once you have a nice system (with typechecks) it doesn't add much complexity though.
Clicking through the variables and hitting an interface definition is much harder to debug too. It can be more hassle to figure out what actual implementation code is running.
Configuration
If you use data fetching libraries (such as tanstack query, RTK Query etc) then injecting in your provided objects/services can be quite complex. Although this is often something which needs to be set up only once.
Over testing with mocks
You can come up with a system that appears to have very high % of code coverage - looking like everything is tested.
But you end up with what is essentially lots of unit tests testing things in isolation.
You can miss out on testing a more realistic integration test without your injected in mocks.
Practically speaking you need to have a bit of both (unit tests with mock injected services & full integration tests). Using injected in mocks leads to quicker and easier development. But don't forget that you do need to do full integration tests to check the separate parts of your system really do work well together.
Let me know your experiences of injecting in React apps
Please leave a comment, tell me what tools/libraries you have used. I asked a few friends about this, and it turns out more people used DI than I thought - although most of them just set it up without calling it DI. I'm sure if we asked backend engineers they would rave all day about DI, as it seems much more popular/common on backend applications.