Dependency Injection

13,532 words

Post contents

One of the core tenets of components we've used repeatedly in the book is the idea of component inputs or properties.

While component inputs are undoubtedly helpful, they can be challenging to use at scale when you need the same set of data across multiple layers of components.

For example, let's look back at the files app we've been developing throughout the book.

A cloud-hosted files style application with a table of files and folders

Here, we have a list of files and the user's profile picture in the corner of the screen. Here's an example of what our data for the page looks like:

const APP_DATA = {	currentUser: {		name: "Corbin Crutchley",		profilePictureURL: "https://avatars.githubusercontent.com/u/9100169",	},	collection: [		{			name: "Movies",			type: "folder",			ownerName: null,			size: 386547056640,		},		{			name: "Concepts",			type: "folder",			ownerName: "Kevin Aguillar",			size: 0,		},	],};

This data has been shortened to keep focus on the topic at hand.

With this data, we can render out most of the UI within our mockup above.

Let's use this data to build out some of the foundations of our application. For example, say that every time we see an ownerName of null, we'll replace it with the currentUser's name field.

To do this, we'll need to pass both our collection and currentUser down to every child component.

Let's use some pseudocode and mock out what those components might look like with data passing from the parent to the child:

// This is not real code, but demonstrates how we might structure our data passing// Don't worry about syntax, but do focus on how data is being passed between componentsconst App = {	data: APP_DATA,	template: (		<div>			<Header currentUser="data.currentUser" />			<Files files="data.collection" currentUser="data.currentUser" />		</div>	),};const Header = {	props: ["currentUser"],	template: (		<div>			<Icon />			<SearchBar />			<ProfilePicture currentUser="props.currentUser" />		</div>	),};const ProfilePicture = {	props: ["currentUser"],	template: <img src="props.currentUser.profilePictureURL" />,};const Files = {	props: ["currentUser", "files"],	template: (		<FileTable>			{props.files.map((file) => (				<FileItem file="file" currentUser="props.currentUser" />			))}		</FileTable>	),};const FileItem = {	props: ["currentUser", "file"],	template: (		<tr>			<FileName file="props.file" />			<LastModified file="props.file" />			<FileOwner file="props.file" currentUser="props.currentUser" />			<FileType file="props.file" />			<FileSize file="props.file" />		</tr>	),};const FileOwner = {	props: ["currentUser", "file"],	data: {		userNameToShow: props.file.ownerName || props.currentUser.name,	},	template: <td>{{ userNameToShow }}</td>,};render(App);

While this isn't real code, we can make a discovery by looking at our code laid out like this: We're passing currentUser to almost every single component!

If we chart out what the flow of data looks like, our currentUser property is being passed like so:

A large component tree that starts with a root of "App" and has lines passing through children into "Header" and "FileItem"

While it's obnoxious to pass currentUser in every component, we need that data in all of these components, so we can't simply remove the inputs, can we?

Well, we can! Sort of...

While we can't outright remove the ability to pass the data from the parent to the children, what we can do is pass these components implicitly instead of explicitly. This means that instead of telling the child component what data it should accept, we simply hand off data regardless of whether it's needed or not. From there, it's the child component's job to raise its hand and ask for data.

Think of this like a buffet of food. Instead of serving food directly to the customer's table, the customer comes to the table with all the food, takes what they want, and is satisfied with the results all the same.

A collection of state data is roughly akin to a buffet dinner

We do this method of implicit data passing using a methodology called "dependency injection".

Providing Basic Values with Dependency Injection

When we talk about dependency injection, we're referring to a method of providing data from a parent component down to a child component through implicit means.

Using dependency injection, we can change our method of providing data to implicitly pass data to the entire application. Doing so allows us to redesign the app and simplify how data is fetched to reflect something like this:

A large component tree that starts with a root of "App" and has lines coming from App's "DI" and passing implicitly past children to get to "Header" and "FileOwner"

Here, FileOwner and ProfilePicture are grabbing data from the App provided value rather than having to go through every individual component.

React, Angular, and Vue all have methods for injecting data implicitly into child components using dependency injection. Let's start with the most basic method of dependency injection by providing some primitive values, like a number or string, down to a child component.

In the React world, all dependency injections are powered by a createContext method, which you then Provide to your child components. You consume the provided data from those child components with a useContext hook.

import { createContext, useContext } from "react";// We start by creating a context nameconst HelloMessageContext = createContext();function Parent() {	return (		// Then create a provider for this context		<HelloMessageContext.Provider value={"Hello, world!"}>			<Child />		</HelloMessageContext.Provider>	);}function Child() {	// Later, we use `useContext` to consume the value from dependency injection	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage}</p>;}

React DI Basic Values String - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react";// We start by creating a context nameconst HelloMessageContext = createContext();function Parent() {	return (		// Then create a provider for this context		<HelloMessageContext.Provider value={"Hello, world!"}>			<Child />		</HelloMessageContext.Provider>	);}function Child() {	// Later, we use `useContext` to consume the value from dependency injection	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage}</p>;}createRoot(document.getElementById("root")).render(<Parent />);

Here, we expect this component to show a <p> tag that renders out "Hello, world!".

While this is convenient for passing simple values to multiple parts of the app, most usages of dependency injection tend to have more complex data provided. Let's extend this logic to provide an object to children instead.

As we mentioned before, all of React's dependency injection logic uses createContext, Provider, and useContext. As such, to provide an object is a minimal change from before, done by changing the value we pass to our provider:

const HelloMessageContext = createContext();const Child = () => {	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage.message}</p>;};const Parent = () => {	const helloMessageObject = { message: "Hello, world!" };	return (		<HelloMessageContext.Provider value={helloMessageObject}>			<Child />		</HelloMessageContext.Provider>	);};

React DI Basic Values Object - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react";const HelloMessageContext = createContext();const Child = () => {	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage.message}</p>;};const Parent = () => {	const helloMessageObject = { message: "Hello, world!" };	return (		<HelloMessageContext.Provider value={helloMessageObject}>			<Child />		</HelloMessageContext.Provider>	);};createRoot(document.getElementById("root")).render(<Parent />);

Changing Values after Injection

While providing values from a parent node down to a child component is useful on its own, it's made even more potent by the inclusion of data manipulation.

For example, what happens when your user wants to change their name with some kind of rename functionality? You should be able to change how the data is stored in your dependency injection to propagate those changes immediately throughout your whole application.

Because our Provider can pass down values of any kind, we can combine this with useState to allow React to update the values for children.

const HelloMessageContext = createContext();const Child = () => {	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage}</p>;};const Parent = () => {	const [message, setMessage] = useState("Initial value");	return (		<HelloMessageContext.Provider value={message}>			<Child />			<button onClick={() => setMessage("Updated value")}>				Update the message			</button>		</HelloMessageContext.Provider>	);};

React Change After Val Inject - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext, useState } from "react";const HelloMessageContext = createContext();const Child = () => {	const helloMessage = useContext(HelloMessageContext);	return <p>{helloMessage}</p>;};const Parent = () => {	const [message, setMessage] = useState("Initial value");	return (		<HelloMessageContext.Provider value={message}>			<Child />			<button onClick={() => setMessage("Updated value")}>				Update the message			</button>		</HelloMessageContext.Provider>	);};createRoot(document.getElementById("root")).render(<Parent />);

When we update the message value, it will trigger a re-render on the Child component and, in turn, update the displayed message.

Changing Injected Values from Child

The previous section showed how to change the injected value from the component's root. But what if we wanted to change the injected value from the child component instead of from the root?

Because dependency injection usually only goes in one direction (from the parent to the child), it's not immediately clear how we can do this.

Despite this, each framework provides us the tools to update injected values from the children themselves. Let's see how that's done:

Previously, we used the ability to use useState in our Provider to handle data changes from the parent provider. Continuing on this pattern, we'll utilize useState once again to handle changes in a child component.

This works because React's useContext enables us to pass data of any kind, functions included. This means that we can pass both the getter and setter functions of useState, like so:

const HelloMessageContext = createContext();function Parent() {	const [message, setMessage] = useState("Initial value");	// We can pass both the setter and getter	const providedValue = { message, setMessage };	return (		<HelloMessageContext.Provider value={providedValue}>			<Child />		</HelloMessageContext.Provider>	);}function Child() {	// And later, access them both as if they were local to the component	const { message, setMessage } = useContext(HelloMessageContext);	return (		<>			<p>{message}</p>			<button onClick={() => setMessage("Updated value")}>				Update the message			</button>		</>	);}

React Change Val From Child - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext, useState } from "react";const HelloMessageContext = createContext();function Parent() {	const [message, setMessage] = useState("Initial value");	// We can pass both the setter and getter	const providedValue = { message, setMessage };	return (		<HelloMessageContext.Provider value={providedValue}>			<Child />		</HelloMessageContext.Provider>	);}function Child() {	// And later, access them both as if they were local to the component	const { message, setMessage } = useContext(HelloMessageContext);	return (		<>			<p>{message}</p>			<button onClick={() => setMessage("Updated value")}>				Update the message			</button>		</>	);}createRoot(document.getElementById("root")).render(<Parent />);
Using a Reducer Pattern

Despite useState and useContext making a powerful combination for data passing and updating in dependency injection, it's far from a perfect solution when dealing with large data sets.

For example, what happens if we want to implement a counter that includes an increment and decrement function?

We could pass each individual function through the Provider:

function App() {	const [count, setCount] = useState(0);	const increment = () => {		setCount(count + 1);	};	const decrement = () => {		setCount(count - 1);	};	const set = (val) => {		setCount(val);	};	const providedValue = { count, increment, decrement, set };	return (		<CounterContext.Provider value={providedValue}>			<Child />		</CounterContext.Provider>	);}

React Homegrown Reducer - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext, useState } from "react";const CounterContext = createContext();function App() {	const [count, setCount] = useState(0);	const increment = () => {		setCount(count + 1);	};	const decrement = () => {		setCount(count - 1);	};	const set = (val) => {		setCount(val);	};	const providedValue = { count, increment, decrement, set };	return (		<CounterContext.Provider value={providedValue}>			<Child />		</CounterContext.Provider>	);}function Child() {	const { count, increment, decrement, set } = useContext(CounterContext);	return (		<>			<p>Count is: {count}</p>			<button onClick={increment}>Increment</button>			<button onClick={decrement}>Decrement</button>			<button onClick={() => set(0)}>Set to zero</button>		</>	);}createRoot(document.getElementById("root")).render(<App />);

But doing so creates a substantial amount of noise: each function has a dedicated variable and needs to be passed independently for the useContext to work as intended.


This is where useReducer might come into play. Let's take a step back for a moment and remove the useContext method.

A "reducer" pattern involves a list of actions that the user can take. These actions are provided the current state value, which will be updated based on the returned value from the reducer.

Let's take a look at the most basic version of a reducer that can only count up from 0:

import { useReducer } from "react";const initialState = { count: 0 };function reducer(state, action) {	return { count: state.count + 1 };}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch()}>Add one</button>		</>	);}

React Basic useReducer - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useReducer } from "react";const initialState = { count: 0 };function reducer(state, action) {	return { count: state.count + 1 };}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch()}>Add one</button>		</>	);}createRoot(document.getElementById("root")).render(<App />);

Whenever dispatch is called, it will run the reducer with no arguments for action, and React will automatically pass state for us. Then, when we return inside of the reducer, React will automatically keep track of the returned value as the new state value.

However, this isn't particularly useful and seems like more boilerplate than needed for what's effectively a simple useState. To make useReducer more worthwhile, we need to add more actions.

For example, we'll have an increment and decrement action that will add one and remove one from the state, respectively.

const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		default:			return state;	}}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>		</>	);}

React useReducer Multi Action - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useReducer } from "react";const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		default:			return state;	}}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>		</>	);}createRoot(document.getElementById("root")).render(<App />);

Here, we can pass a type object as a parameter of reducer's action, run a switch/case over it, and return relevant data changes as needed.

But that's not all we can do with a reducer! We can also pass in what's often called a payload to set raw data to our state as well:

const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		case "set":			return { count: action.payload };		default:			return state;	}}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>			<button onClick={() => dispatch({ type: "set", payload: 0 })}>				Set to zero			</button>		</>	);}

React useReducer Payload - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useReducer } from "react";const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		case "set":			return { count: action.payload };		default:			return state;	}}function App() {	const [state, dispatch] = useReducer(reducer, initialState);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>			<button onClick={() => dispatch({ type: "set", payload: 0 })}>				Set to zero			</button>		</>	);}createRoot(document.getElementById("root")).render(<App />);

It's worth mentioning that the reducer pattern is not unique to React. That said, React is unique in that it has a built-in method to build reducers, unlike many other frameworks.

Reducer Patterns within Contexts

Just like we were able to pass the setValue function from useState, we can pass both state and dispatch using our context's Provide and utilize useContext to inject those values into our child components.

const CounterContext = createContext();const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		case "set":			return { count: action.payload };		default:			return state;	}}function Parent() {	const [state, dispatch] = useReducer(reducer, initialState);	const providedValue = { state, dispatch };	return (		<CounterContext.Provider value={providedValue}>			<Child />		</CounterContext.Provider>	);}function Child() {	const { state, dispatch } = useContext(CounterContext);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>			<button onClick={() => dispatch({ type: "set", payload: 0 })}>				Set to zero			</button>		</>	);}

React Reducer Within Contexts - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useReducer, useContext } from "react";const CounterContext = createContext();const initialState = { count: 0 };function reducer(state, action) {	switch (action.type) {		case "increment":			return { count: state.count + 1 };		case "decrement":			return { count: state.count - 1 };		case "set":			return { count: action.payload };		default:			return state;	}}function Parent() {	const [state, dispatch] = useReducer(reducer, initialState);	const providedValue = { state, dispatch };	return (		<CounterContext.Provider value={providedValue}>			<Child />		</CounterContext.Provider>	);}function Child() {	const { state, dispatch } = useContext(CounterContext);	return (		<>			<p>{state.count}</p>			<button onClick={() => dispatch({ type: "increment" })}>Add one</button>			<button onClick={() => dispatch({ type: "decrement" })}>				Remove one			</button>			<button onClick={() => dispatch({ type: "set", payload: 0 })}>				Set to zero			</button>		</>	);}createRoot(document.getElementById("root")).render(<Parent />);

Optional Injected Values

Let's think back to the start of this chapter. The original goal of introducing dependency injection was to enable the sharing of user login information throughout multiple components.

While you might expect the user's login information to always be present, what if it wasn't? What if, when the user first creates their account, they opt out of inputting their name and profile picture? Even if this seems unlikely, a robust application should handle edge cases like this.

Luckily, React, Angular, and Vue can all withstand an empty value provided through dependency injection by marking the value as "optional."

In React, handling optionally injected values doesn't require a new API. We can still use the useContext hook in the child component, even without a provider.

const HelloMessageContext = createContext();function Parent() {	// Notice no provider was set	return <Child />;}function Child() {	// `messageData` is `undefined` if nothing is injected	const messageData = useContext(HelloMessageContext);	// If no value is passed, we can simply	// not render anything in this component	if (!messageData) return null;	return <p>{messageData}</p>;}

When this is done, useContext is undefined if no value is injected for a particular named context.

React Optional Injected Vals - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react";const HelloMessageContext = createContext();function Parent() {	// Notice no provider was set	return <Child />;}function Child() {	// `messageData` is `undefined` if nothing is injected	const messageData = useContext(HelloMessageContext);	// If no value is passed, we can simply	// not render anything in this component	// if (!messageData) return null	// But for now let's show a message	if (!messageData) return <p>There was no data</p>;	return <p>{messageData}</p>;}createRoot(document.getElementById("root")).render(<Parent />);

Default Values for Optional Values

While it's good that our code is now more resilient against missing data, it's not a great user experience to simply have parts of the app missing when said data isn't present.

Instead, let's decide that when the user doesn't have a provided name, let's provide a default value of "Unknown Name" throughout our app. To do this, we'll need to provide that default value in our dependency injection system.

Because of React's minimalistic dependency injection API, providing a default value to an optionally injected value can be done using JavaScript's built-in "OR" operator (||).

function Child() {	const injectedMessageData = useContext(HelloMessageContext);	const messageData = injectedMessageData || "Hello, world!";	return <p>{messageData}</p>;}

React Default Vals for Optional - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react";const HelloMessageContext = createContext();function Parent() {	// Notice no provider was set	return <Child />;}function Child() {	const injectedMessageData = useContext(HelloMessageContext);	const messageData = injectedMessageData || "Hello, world!";	return <p>{messageData}</p>;}createRoot(document.getElementById("root")).render(<Parent />);