Dependency Injection in React with useContext + TypeScript

TL;DR

In React and similar frameworks, you sometimes want to do DI-like things. Specifically, this comes up when mocking SDKs or when implementing in a DDD-like fashion.

In most cases, such Client, Repository, or Service objects don't hold state that triggers React re-renders. So sharing them globally with useContext is unlikely to cause major issues.

With this mindset, the idea of using useContext for DI is fairly common:

The Initialization Problem

Let's define an arbitrary interface and its implementation.

interface SomeInterface {
	test: () => string;
}

class SomeEntity {
	constructor() {}

	test() {
		return "test";
	}
}

If you initialize it naively, it looks like this. However, this isn't ideal when using useContext as a DI container. We want to inject something that implements the interface from the outside, but a specific concrete instance is already injected from the start, creating a dependency on a particular implementation.

import { createContext } from "react";

export const SomeContext = createContext<SomeInterface>(new SomeEntity());

The practical downsides are the dependency on a concrete implementation and the fact that you don't need to provide an initial value to the Provider:

import { useContext } from "react"

import { SomeContext } from "/path/to/context"

const Test = ({}) => {
    const entity = useContext(SomeContext)

    return <>{entity.test()}</>
}

const App = ({}) => {
    return <SomeContext.Provier><Test /></SomeContext.Provider>
}

export default App;

Initializing with undefined

As mentioned above, since we don't want to hold any concrete instance as the initial value, we initialize with undefined.

import { createContext } from "react";

export const SomeContext = createContext<SomeInterface | undefined>(undefined);

This removes the dependency on a concrete implementation, but introduces two new problems: skipping initialization doesn't cause an error, and the return value of useContext becomes SomeInterface | undefined, requiring an undefined check every time.

To solve this, I referenced the following article:

function createCtx<T>() {
	const ctx = React.createContext<T | undefined>(undefined);
	function useCtx() {
		const c = React.useContext(ctx);
		if (!c) throw new Error("useCtx must be inside a Provider with a value");
		return c;
	}
	return [useCtx, ctx.Provider] as const;
}

By using this function, the return value of useCtx becomes T, and failing to initialize the Provider results in an error. Here's how to use it:

const [useSomeContext, SomeContextProvider] = createCtx<SomeInterface>()

const Test = ({}) => {
    const entity = useSomeContext()

    return <>{entity.test()}</>
}

const App = ({}) => {
    return <SomeContextProvier value={new SomeEntity()}><Test /></SomeContextProvider>
}

export default App;

Summary

By initializing the Context with undefined, making it throw an error when DI is not performed, and using a custom hook whose return value doesn't include undefined, you can achieve type-safe DI-like behavior.

Crear un issue en GitHub sobre este artículo

Leer a continuación