Greetings to all readers!
In the world of information technology, there is a steady trend of transition from traditional desktop applications to web applications. Today, web applications have reached significant complexity and represent an interesting area of development. I was lucky enough to participate in the creation of one of these applications, and I am glad to share my experience and knowledge with you.
About the product
I would like to say a few words about the product in the development of which I am involved. It is an IoT platform that helps companies develop and implement solutions to connect, monitor and manage various devices. It provides a wide range of functions, including data collection, event processing, information visualization and integration with other systems.
One of the key features of this platform is the ability to create intuitive dashboards that allow users to visualize data received from devices and monitor their status in real time. Dashboards can be customized to meet specific user needs and display the most important information in a convenient format.
Tools
The project uses React and MobX.
React gives you a lot of freedom in choosing tools and development approaches. Freedom of choice is both an advantage and a potential source of difficulty. We must carefully study the available options, evaluate their advantages and disadvantages, and also take into account the specifics of the project. MobX was chosen to work with the state.
MobX is great for creating interactive dashboards and editors where users can dynamically change data and have those changes reflected instantly in the interface. It also plays particularly well with React, allowing components to be updated efficiently when state changes. Together they form a powerful tandem for creating modern web applications.
About stores in MobX
We have a complex project and there are quite a lot of stores. In case there are many stores, the MobX documentation recommends combining these stores inside the root store:
class RootStore {
constructor(someDep1, ..., someDepN) {
this.userStore = new UserStore(this, someDep1, ..., someDepN);
this.todoStore = new TodoStore(this);
...
this.someAnotherStore = new SomeAnotherStore(this);
}
}
If the store needs any dependencies, they are specified directly in the RootStore.
We initially adopted this approach and followed it for several years.
To make the RootStore available in React components, we passed it through the Provider in which the entire application was wrapped.
SSR
One day, we were faced with the task of ensuring a quick first download of the application. This is facilitated by the addition of support for server rendering. At that time, the application was already huge. We had to make a number of changes to gain more control over the lifecycle of application components.
What could previously be a singleton should no longer be one in SSR mode. For example, the root store, along with all its substores, must be created anew for each user request. Our stores use an http-client to receive data. And before they received this dependency implicitly, through import :) And within the client, the user’s cookies are saved, which means the client must also be created separately for each user request and transferred to the store.
Looking for X
The title of the article suggests solving an equation :) We also faced this task, because we found ourselves in a situation where we do not have a framework as such on our project, but the application is very complex and contains a lot of logic. More stores began to appear. In addition to stores, there are other components of the application, and there are certain dependencies between them.
We realized that it was time to manage dependencies centrally.
Selecting an IOC container
We looked at existing popular libraries that provide IOC container functionality, InversifyJS is one of them. But we felt that these libraries were redundant for us and weighed quite a lot. As a result, a very simple and lightweight library called vorarbeiter was born. t can be used in both TypeScript and JavaScript projects, it does not use decorators, thus, when building the project, additional JavaScript is not generated, which makes the project heavier. It can also be used both in the browser and on the server. And the library does not entail any additional dependencies.
Basic concepts of Vorarbeiter
- Dependency resolution:
During service creation. Dependencies are set in Factories. This approach was chosen because it is universal. Thus, we perform injection through the constructor - the most preferred method.
After creating the service. You can specify an Injector that runs immediately after the service instance is created. Within it, you can inject dependencies through properties or through setters. Can be used to add optional dependencies or to bypass cyclic dependencies.
- Service caching strategy:
Shared - a service instance is created once for the lifetime of the entire application, this is the default behavior.
Transient - a service instance is created anew each time we request a service.
Scoped - an instance of a service will only be the same within a specific context, and we can tell the service how to understand the context in which it is being accessed.
The following example shows how to use a factory to pass dependencies, how to create service definitions, and how to then use them:
import { createServiceSpecBuilder, ServiceFactory } from "vorarbeiter";
interface Car {
getDriverName(): string;
}
class CarImpl implements Car {
constructor(private readonly driver: Driver) {}
getDriverName() {
return this.driver.getName();
}
}
interface Driver {
getName(): string;
}
class DriverImpl implements Driver {
getName() {
return "Michael Schumacher";
}
}
class CarFactory implements ServiceFactory {
create(container: ServiceContainer): CarImpl {
const driver = container.get("driver");
return new CarImpl(driver);
}
}
const specBuilder = createServiceSpecBuilder();
specBuilder.set("car", new CarFactory());
specBuilder.set("driver", () => new DriverImpl());
const spec = specBuilder.getServiceSpec();
const serviceContainer = createServiceContainer(spec);
const car: Car = serviceContainer.get("car");
console.log(car.getDriverName()); // Michael Schumacher
And here's how to apply dependency injection through a property and through a setter using the Injector.
specBuilder.set("injectorService", () => {
return new class {
car!: Car;
driver!: Driver;
setDriver(driver: Driver) {
this.driver = driver;
}
};
}).withInjector((service, container) => {
service.car = container.get("car");
service.setDriver(container.get("driver"));
});
This is how a transient service is declared:
const specBuilder = createServiceSpecBuilder();
specBuilder.set("myService", () => ({
serviceName: "My service"
})).transient();
const spec = specBuilder.getServiceSpec();
const serviceContainer = createServiceContainer(spec);
console.log(
serviceContainer.get("myService") ===
serviceContainer.get("myService")
); // false
To declare a scoped service, you also need to specify how to get the context in which the service is requested, for example:
const asyncLocalStorage = new AsyncLocalStorage<object>();
specBuilder
.set("myScopedService", () => ({
serviceName: "Awesome service"
}))
.scoped(() => asyncLocalStorage.getStore());
const spec = specBuilder.getServiceSpec();
const serviceContainer = createServiceContainer(spec);
let scopedService1;
let scopedService2;
asyncLocalStorage.run({}, () => {
scopedService1 = serviceContainer.get("myScopedService");
scopedService2 = serviceContainer.get("myScopedService");
});
let scopedService3;
let scopedService4;
asyncLocalStorage.run({}, () => {
scopedService3 = serviceContainer.get("myScopedService");
scopedService4 = serviceContainer.get("myScopedService");
});
console.log(scopedService1 === scopedService2); // true
console.log(scopedService1 === scopedService3); // false
Here we use AsyncLocalStorage from node.js. It's perfect for running code in different contexts.
React Integration
To easily integrate Vorarbeiter with React, you can use the vorarbeiter-react library.
The implementation of Vorarbeiter in React occurs through the Provider.
import React, { FC } from "react";
import {
createServiceContainer,
createServiceSpecBuilder,
ServiceContainer
} from "vorarbeiter";
import { ServiceContainerProvider } from "vorarbeiter-react";
import { ServiceImpl } from "./path/to/service/impl";
import { App } from "./path/to/app";
export const RootComponent: FC = () => {
const sb = createServiceSpecBuilder();
sb.set("someService", () => new ServiceImpl());
const serviceContainer = createServiceContainer(sb.getServiceSpec());
return (
<ServiceContainerProvider serviceContainer={serviceContainer}>
<App />
</ServiceContainerProvider>
);
};
We can then get our container in the functional components using the useServiceContainer hook:
import React, { FC } from "react";
import { useServiceContainer } from "vorarbeiter-react";
import { Service } from "./path/to/service";
const MyComponent: FC = () => {
const serviceContainer = useServiceContainer();
const someService: Service = serviceContainer.get("someService");
return (
<div>{someService.someFieldValue}</div>
);
};
And in class components we can use HOC withServiceContainer:
import React from "react";
import { withServiceContainer } from "vorarbeiter-react";
import { Service } from "./path/to/service";
const MyComponent = withServiceContainer(
class MyComponent extends React.Component {
render() {
const { serviceContainer } = this.props;
const someService: Service = serviceContainer.get("someService");
return (
<div>{someService.someFieldValue}</div>
);
}
}
);
Context API is similar to ServiceLocator
The IOC container, not only Vorarbeiter, manages the creation of services and stores them. But he cannot deal with dependency injection into React components, because React itself handles the life cycle of these components. Dependencies are transferred to the component by the parent component via props, or they are taken from the context via the Context API. Using one context for the entire application and the ability to take anything from it is reminiscent of the ServiceLocator approach, which has a number of disadvantages. In general, the very idea of using the Context API is not ideal, but I think it’s just the lesser of two evils, because on the other side of the scale is passing everything through props and the props drilling problem.
IOC in React
We can inject a dependency into a React component via a hook or HOC. But inversion of control will still occur in favor of the parent component, or in favor of the hook, but not in favor of the IOC container. So you need to understand that React uses an IOC approach, but there is no IOC container. Dependency injection is done by the programmer himself when he writes components. Even if we use some kind of library with an IOC container, one way or another we ourselves will take the necessary services from it and implement them into React components. An IOC container is needed to organize work with system components outside of React.
What happened in the end
After introducing Vorarbeiter into our project, we were able to get rid of RootStore, within which we manually resolved dependencies when creating stores, and which was also a container for these stores. Now these tasks are performed by the Vorarbeiter IOC container. It also now manages all dependencies, not just stores. We now treat stores as a special case of services.
Now our application, when rendering on the server and when rendering in the browser, simply configures the container differently at the very beginning, and then all services are used in the same way: for example, in the browser, the stores are the only instances within the entire application, and when rendering on the server, the stores are unique only within each user request, but when used locally, this is no longer necessary to know.
Now if we want to add some functionality, which is essentially some kind of service, for example, Logger, we know where to place it and how to register it. Previously, there were problems with this, because it was necessary to do something to make this functionality available in React components. You can’t place everything in the RootStore, and it’s not convenient to configure dependencies manually every time. Receiving via import is not the best practice, since the dependency is implicit. Pass everything through props - we get props drilling. Wrapping it in a bunch of providers is overkill. This means that you need to convey something once, from where you can get what you need. This is now the Vorarbeiter IOC container.
As a result, we managed to solve the equation.
The answer was: x = Vorarbeiter.
Thank you everyone for your interest in this topic! I will be glad if our solution is also useful to someone :)
Links to libraries:
IOC container: vorarbeiter
React integration: vorarbeiter-react
Top comments (0)