DEV Community

Cover image for Find x: React + MobX + SSR + x = Happiness
Viacheslav Muravyev
Viacheslav Muravyev

Posted on • Edited on

1 1 1

Find x: React + MobX + SSR + x = Happiness

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);
 }
}
Enter fullscreen mode Exit fullscreen mode

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. It 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

  1. 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.

  1. 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
Enter fullscreen mode Exit fullscreen mode

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"));
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
 );
};
Enter fullscreen mode Exit fullscreen mode

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>
 );
};
Enter fullscreen mode Exit fullscreen mode

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>
     );
   }
  }
);
Enter fullscreen mode Exit fullscreen mode

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

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (5)

Collapse
 
szagi3891 profile image
Grzegorz Szeliga

and as for the mobx itself, I have the same experience as you. It is sensational :)

Collapse
 
szagi3891 profile image
Grzegorz Szeliga • Edited

I approached this differently. I use something like singletons, but they are created within a single object that I call "common".

On the server side, I create this object for each request. Then, during processing request, all "singletons" are created within this object. Once the request is handled, I unregister all the singletons associated with the common object for that particular request.

Example code:

    class Common {
        public [autoWeakMapKey](): void {} //autoWeakMapKey - js symbol
        public constructor(public readonly name: string) {}
    }

    class Model {
        public static get = AutoWeakMap.create((common: Common, id1: string, id2: number) => {
            const model = new Model(common, id1, id2);
            memoryHelper.register(model, `${common.name}-${id1}-${id2}`);
            return model;
        });

        private constructor(private readonly common: Common, private readonly id1: string, private readonly id2: number) {}

        public get name(): string {
            return `Model - ${this.common.name} - ${this.id1} - ${this.id2}`;
        }
    }
Enter fullscreen mode Exit fullscreen mode
        const common = new Common();
        AutoWeakMap.register(common);

        const model1 = Model.get(common, 'aaa', 111);
        expect(model1.name).toBe('Model - CommonI - aaa - 111');

        const model2 = Model.get(common, 'bbb', 222);
        expect(model2.name).toBe('Model - CommonI - bbb - 222');

        const model3 = Model.get(common, 'ccc', 555);
        expect(model3.name).toBe('Model - CommonI - ccc - 555');

        AutoWeakMap.unregister(common); //Deallocation of all "singletons"
Enter fullscreen mode Exit fullscreen mode

I also have a guarantee that if I refer to the object using the same ID multiple times, I will always get the same instance of the object.

const model4 = Model.get(common, 'ccc', 555);
const model5 = Model.get(common, 'ccc', 555);
Enter fullscreen mode Exit fullscreen mode

model4 and model5 are the same instance of the object.

Collapse
 
viacheslav_muravyev_af526 profile image
Viacheslav Muravyev • Edited

Ok, as an option. But then:

  • We should unregister a service manually
  • We should do it wherever the scope ends, for example:
function requestHandler(req, res) {
    // Some logic
    // ...
    if (condition1) {
        // ...
        AutoWeakMap.unregister(common); //Deallocation of all "singletons"
        return;
    }

    // Some logic
    // ...
    if (condition2) {
        // ...
        AutoWeakMap.unregister(common); //Deallocation of all "singletons"
        return;
    }

    // Some logic
    // ...
    if (condition3) {
        // ...
        AutoWeakMap.unregister(common); //Deallocation of all "singletons"
        return;
    }
}
Enter fullscreen mode Exit fullscreen mode

If we forget to clean up the instance store, we will get a memory leak.

  • We should write some logic to store instances per context

If we use Vorarbeiter, everything happens transparently. Once we declare our service as scoped we can forget about we can have many instances of a service per a context (user's request for example). Under the hood Vorarbeiter uses native WeakMap to store instances within contexts, so, the garbage collector will take care of deleting unnecessary service instances, not a programmer.

Anyway thanks for your experience!

Collapse
 
szagi3891 profile image
Grzegorz Szeliga

Yes, my solution has the drawback that I need to remember to unregister the common object.

I was considering AsyncLocalStorage, but it's not perfect. Do you have any issues with it?

Thread Thread
 
viacheslav_muravyev_af526 profile image
Viacheslav Muravyev

Generally Vorarbeiter doesn't depend on AsyncLocalStorage. We simply use AsyncLocalStorage in the Context Resolver:

const asyncLocalStorage = new AsyncLocalStorage();
// Here we define the Context Resolver
const myContextResolver = () => asyncLocalStorage.getStore();
specBuilder
 .set("myScopedService", () => ({ 
    serviceName: "Awesome service" 
 }))
/*
Here we use our Context Resolver 
to describe to the Service Container 
how to understand for which context 
it should return a service instance
*/
 .scoped(myContextResolver);
Enter fullscreen mode Exit fullscreen mode

We can replace the Context Resolver with one that won't use AsyncLocalStorage.
AsyncLocalStorage is not a part of Vorarbeiter.

You are right, it may seem that async context tracking has issues. Here's what the node.js documentation says about it:

Async context tracking use cases are better served by the stable AsyncLocalStorage API

So, some functions are unstable, but some functions are stable.
asyncLocalStorage.run and asyncLocalStorage.getStore don't mark as unstable.
I haven't encountered any problems with this yet.

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs