Why the DI options in React do not pass the CATE test? What imperfect solution is possible? And can it make React development easier and more fun?
A little bit of bias
I come from an object-oriented .Net and C# environment, where the use of Dependency Injection is visible in every step of development. Hence when I recently started working on a React project and digging up the functional approach for doing UI, me and my teammates wondered, whether we could use some of the experience from former projects to facilitate work on the current one. This included trying some approaches and patterns from other frameworks and programming styles.
Like a dependency injection mechanism, which makes our lives as developers tremendously simpler. It is not a concept bound to OOP only as it can be used to the same extent in even a fully functional programming style. So the question we asked, was not if we can make it in a functional way, but if we can make it React.
I contested this topic and decided to share my conclusions.
Why inject anything?
But a bit of basic knowledge first.
In short: Dependency injection is one of the implementations of Inversion of control in programming. It is all about passing the dependencies of the component from the outside instead of the component creating them by itself.
Let's see some examples: given we have a service that makes HTTP calls for us like this:
export class HttpClient {
constructor(private _cfg: { retryTimes: number }){ }
get: <TResult>(url: string) => {
// implementation
}
}
The non-inverted API client call code using it could look like this:
const getUsers = (): User[] => {
// create some hypothetical client
const client = new HttpClient({ retryTimes: 3 });
return client.get<Users[]>("http://myapi.com/api/v1/users");
}
And the one with inverted dependencies - like this:
const getUsers = (client: HttpClient): User[] => {
return client.get<User[]>("http://myapi.com/api/v1/users");
}
See the difference? We moved the client from the internal variable to the function parameter. And that simple thing makes our dependency on the client inverted and injectable from the outside. This gives us multiple benefits:
Dependency Injection benefits |
β Loosely coupled components |
β Explicit dependencies |
β Higher modularity of code pieces |
β Helps achieve single responsibility and interface segregation principles |
β With abstracted dependencies (e.g. behind interfaces or type signatures): |
β Testing code in isolation |
β Exchanging dependencies without breaking code |
As you may have noticed, in the example I used a function and not a class. Just for you to realize, that this is how you do it in functional programming. And how easy it is. Heck, you can go full functional and just pass the 'get' function as an argument to this one, and you have the same decoupling done.
CATE - Characteristics of a good DI mechanism
Yeah, I made this acronym up, but maybe this way it will stick for longer ;)
Here are four properties in my opinion good DI library should provide:
Configurable - Allows for multiple options of configuring services creation: singleton, instance, factory etc. along with hiding implementation behind abstraction.
Automated - A resolution graph is created out of the configured dependencies and services are supplied as requested without developer action. Some call it auto-wiring. The mechanism also validates required dependencies beforehand and breaks circular references if any.
Transparent - Creating and injecting dependencies where they are required is done behind the scenes without explicit developer action. You just use the code as it was there.
Explicit - Dependencies are visible for any developer using the component or service, without the need to read the internal implementation (for example when they are using code from the external library). This means they are present at the public point of entry to the code be it the constructor of class or function signature.
There are many other properties like scopes, services lifetime control, services disposal, asynchronous resolution etc. But these four things I find a must-have for the DI to facilitate and not complicate the work of developers.
Dealing with dependencies in React
So how do I imagine this to work in React? In an ideal world, each created component should have its props fields supplied from the DI mechanism if not defined beforehand.
export interface IMyProps {
message: string, // Some prop passed from parent
phoneService: IPhoneService // <-- interface implementation injected
}
export MyComponent: React.FC<MyIProps>() = (props) => {
props.phoneService.callMe();
};
Let's check what DI options we can use in React and Typescript world and if they pass the CATE test.
Props
Dependencies are passed to components as prop fields. Just like in the example from the previous section.
β The only way of injecting dependencies from outside to functional components (constructor params for class components)
β Can pass state along with functions, reducers or service-like objects.
β Can be cumbersome, when diving deep into many levels of the component tree passing dependencies down (aka 'props-drilling').
β Requires parent component to hold dependencies of all its children.
Context
Dependency can be requested by the component via useContext
hook. The context can hold service value and let child components reuse it.
export MyComponent: React.FC = () => {
const phoneService = usePhoneService(); // returns IPhoneService instance
phoneService.callMe();
};
β Can be used to share services between components without explicitly passing them in the tree. See in-depth articles by Tommy Groshong or Daniel Hansen for a detailed explanation.
β Tied to specific parent context (which is fine as can be treated as dependency scope).
β The component needs to explicitly ask for it, which is known as the 'Service Locator' pattern and is considered flawed in most cases as it introduces hidden dependencies to the implementation.
β Hinders explicitness and reusability: For the component to be used elsewhere, we need to provide context value in its new parent tree. But how do we know it is needed if the component does not expose it directly? We don't. We get to know either by reading the documentation (or rather implementation in most cases) or catching the first runtime error.
Third-party dependency injection
Some common and uncommon libraries do DI in typescript. I found multiple ones and checked briefly: how they work and what are possible drawbacks. Explaining every one can be an article itself, hence I will just extract some of their common characteristics.
Each of the libraries is built around the concept of container (context, provider, injector etc.) general source of dependencies configuration.
Each of them requires dependencies to be keyed (mostly by string literals)
But based on their approach to setting up this configuration they can be divided into two groups:
Implicit - Decorators and metadata-based configuration.
Explicit - With dependencies configured 'by hand'.
Metadata based libraries
TypeScript decorators are used here for defining type metadata used later in constructing dependency resolution graphs behind the scenes. The metadata holds information about the dependency key, scope, injectable dependencies etc. The library just reads all of them and configures the container automatically.
Examples: TSyringe, InversifyJS, TypeDI, Fusion
// sample use of tsyringe
// 1. decorating services
export interface ILogger { logInfo: (msg: string) => void }
@injectable() // decorate dependency with 'injectable'
export class ConsoleLogger implements ILogger {
logInfo = (msg: string) => console.log(msg);
}
@injectable()
export class MyService {
constructor(@inject("logger") private _logger: ILogger);
}
// 2. registering interface implementations by keys
container.register<ILogger>("logger", { useClass: ConsoleLogger });
// 3. resolve from container somewhere in code
const myServiceInstance = container.resolve(MyService);
β Auto-wiring (dependency tree is constructed by the library).
β Decentralized configuration allows easier addition of new module dependencies.
β Dispersed configuration makes it harder to maintain dependencies.
β Requires configuring the project to use the experimental feature (Which IMO seems not so bad, as the last breaking change was introduced in 2022)
// tsconfig
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// other configuration
}
Explicitly configured libraries
Dependencies and relations between them are described explicitly in code by developers, either when configuring container or registered types.
Whichever is the case, libraries of this category lack one of the feature, the decorator ones have:
β They do not support auto-wiring. You need to configure all dependencies manually.
Example with dependencies declared directly in classes: TypedInject
// Typed Inject example
// classes cfg
export interface ILogger { logInfo: (msg: string) => void }
export class ConsoleLogger implements ILogger {
logInfo = (msg: string) => console.log(msg);
}
// add 'inject' tokens array matching constructor parameters
export class MyService {
public static inject = ['logger'] as const;
constructor(private _logger: ILogger);
}
// setup container (injector)
const appInjector = createInjector()
// add logger instance by shared key
.provideValue('logger', new ConsoleLogger())
.provideClass('MyService', MyService);
// resolve dependenciesand create instance
const myService = appInjector.injectClass(MyService);
This way has the same pros and cons as the decorator-based solutions.
Example with explicit container setup: RSDI
// RSDI example
// Setup container
const container = new DIContainer();
container.add({
// Add siingleton logger with 'logger' key
logger: new ConsoleLogger(),
// Add service with use of declared 'logger' dependency
[MyService.name]: object(MyService).construct(use(logger))
});
return container;
}
// Resolve dependencies and create instance
const myService = container.get(MyService);
Configuration here is concentrated in one place.
β This can be useful for keeping better control of what is registered and where.
β But need to explicitly tell which dependency is used where in a tree means a lot of manual work if the tree gets complicated or the types used change a lot.
CATE test
Let's see if the mentioned solutions can satisfy the four CATE characteristics regarding React components creation:
Props | Context | Metadata libraries | Explicit libraries |
β Configurable | β Configurable | β Configurable | β Configurable |
β Automated | β Automated | β Automated | β Automated (semi) |
β Transparent | β Transparent | β Transparent | β Transparent |
β Explicit | β Explicit | β Explicit | β Explicit |
Seems all our solutions besides props lack explicitness. We need to provide something, that does not hide dependencies and yet resolves them for us. Given the options, we have today it leads me to the conclusion that...
DI in React is not possible
At least not in the way that:
Makes components reusable and does not Introduce hidden dependencies.
Does not require explicit action to resolve components with dependencies.
These two characteristics are in my opinion necessary for the DI solution to be practical. But why do I think they can not be achieved when using React?
Dependencies flow vs components flow
All of this can be compiled into a simple diagram comparing the flow of dependencies between services and the flow of components hierarchy in React.
For services we inject more specific ones into a more generic one, that is to be used as a point of entry. The ultimate service we use depends on others, the relations tree is rooted in the service we use. This way we can easily decide the behavior of service by redefining its dependencies from the outside. Inversion of control is present at its best.
On the other hand components flow in React goes the other way. A more generic component (parent) creates specific sub-components (childs). For the children to get the required dependencies, they must come from the parent. Which in practice, would require parent to have all the dependencies for all its children all the way down in the hierarchy. This is impractical and does not benefit the development process much.
Injecting the props (not good enough)
The only way to properly manage component dependencies would be by injecting them on the fly as props fields. Filling all the required properties of component, that are not supplied by its parent. And do it transparently.
Why? Because every time we are forced to use some object or function to resolve dependency at any point in time, we go back to the service locator pattern and the problems it introduces. And so the circle continues.
One could argue, that this resolution is similar to the use of a container to resolve all services. As it is a bag of registrations used to create other objects. But the reverse hierarchy of dependencies allows for resolving the required one with a single call to container, and not caring about it anymore.
It is not possible in the React components tree. At least not in its current state. React does not support it. It won't be possible unless there is a built-in way to intercept passing properties to components so that some code could modify them on the fly, without developers' explicit call.
Imperfect solution
But fear not. The world is not perfect, we know. And even if there is no solution fulfilling all my requirements, I am willing to take some exceptions and use something acceptable.
There is a mix of approaches that I find plausible. These are the steps to achieve it:
Configure the container with one of the libraries of your choice.
Setup container in React Context - to scope dependencies (optional, as you can use container directly if you wish).
Define all dependencies in component props.
type IMycomponentProps = { message: string, myService: IMyService // <-- need this injected }
Write function resolving React components by injecting missing props with the use of a container (HoC wrapper).
Its implementation could use dependencies keys configuration typed like this:const configuration = { keySameAsInProps: "Value as dependency identifier in container", otherKeyInProps: "Other Identifier from container" // etc... }
Original props and configuration as above could be then consumed by HoC function similar to the one below:
export function injectableReactComponent< P extends Record<string, any>, // Original props type PDependencies extends Record<string, string>> // Dependencies keys ( WrappedComponent: React.ComponentType<P>, dependencies: PDependencies): React.ComponentType<P> { return (props: P) => { const dependenciesProps: any = {}; // Fill in dependencies with resolved instances Object.entries(dependencies) .forEach(([ key, value ]) => { if(!props[key]){ // resolve and assign to key as in configuration dependenciesProps[key] = Container.resolve(value); } }); // pass dependencies along with rest of original props // they will get mixed together into wrapped component return (<WrappedComponent {...dependenciesProps} {...props} />); }; }
Wrap each dependent component into this function and export the wrapped instance instead of the original one.
export default injectableReactComponent( MyComponent, { myService: InjectionKeys.MyService } // string key
In this solution, we can keep the dependencies in props so they are explicit and visible. We then either pass them from a parent or resolve them automatically from the pre-configured container.
We leave the possibility to inject mock implementations of services to any component, thus significantly improving its testability.
It is also kind of transparent for developers using our components later on, as they do not use the HoC wrapping, we did it for them. It is not the perfect transparency, but it is good enough.
Lessons learned
As I mentioned in my article about learning frontend frameworks, challenging the way things are done, and mixing concepts can lead to dead-ends but also to interesting results. It was both this time.
I could not get the concept working the same way I am used to it in .Net, and it is fine as it is a different framework and another programming language, things don't work the same here. You just need to switch your mindset, get used to the new way and embrace its advantages.
On the other hand, I learned a lot. Thanks to this I managed to come up with a satisfying solution, that I will most likely use in real commercial code. Will let you know, how it went.
I hope you also learned something from this elongated article. If you did, or if I have mistaken something, missed some way to do DI in React, or perhaps you know some awesome library for DI in React, then don't hesitate and leave a comment.
Thank you! Have a nice day and keep on learning :)