Our team has been on the long (and I do mean loooong – the design document was written two years ago!) road of rewriting our AngularJS application to React. With the help of the react2angular package we’ve been able to have a hybrid app and slowly convert AngularJS components to React. Finally, there’s a faint light at the end of the tunnel where we can almost see a pure React app. Most components have been converted, but there are a few AngularJS things that are harder to disentangle because of how widespread they are: ui-router, angular-translate, and the injection system native to AngularJS.
Remember the time before the Typescript or ES6 ability to import and the seeming magic of the AngularJS injection system? It was one of the first large Javascript libraries that attempted to solve the problem of dependency injection. Those building AngularJS applications were encouraged to separate concerns through the creation of services and AngularJS would take care of creating singletons from them injecting into them any required arguments. Unfortunately, our app is now several years in and has more than a hundred services and stores (our Baobab immutable state objects). They form a complex acyclic graph and there’s no easy way to figure out how to untangle them. React comes with no built-in service for dependency injection or managing singletons, so I’ve been working on figuring out how we can replace the AngularJS one, but ideally without a huge amount of churn.
After some research InversifyJS surfaced as a solid alternative. It integrates nicely with Typescript and React (via inversify-react) and has a robust set of features. Rewriting every AngularJS service in a single massive commit is less than ideal, so I started experimenting with how the two injection systems could co-exist so we could follow a similar path as components and convert one service at a time.
The problem code
Here’s a contrived example of where we started, a couple of AngularJS services, that have some dependency on each other:
export type TimezonesService = ReturnType<typeof timezones>;
export function timezones() {
let timezones;
const service = {
get timezones() {
return timezones;
},
findTimezone(name) {
return timezones.find(tz => tz.name === name);
}
};
initialize();
return service;
function initialize() {
timezones = moment.tz();
}
}
export type UserTimezoneService = ReturnType<typeof userTimezone>;
export function userTimezone(timezoneService: TimezonesService) {
const service = {
findForUser(userZone) {
return timezoneService.findTimezone(userZone);
}
}
return service;
}
import { timezones } from 'timezones.service';
import { userTimezone } from 'userTimezone.service';
angular.module('Timezones', [])
.service('timezoneService', timezones)
.service('userTimezoneService', userTimezone);
The solution
To get these services to work with InversifyJS (and thus the useInjection
React hook that comes with inversify-react) we need to get them in a form that is recognizable by Inversify, but still usable in AngularJS. The first step is to convert them from functions to classes, since InversifyJS needs a class to instantiate. This requires a bit of rewrite, but it is largely straightforward and comes with the benefits of a more understandable class pattern and not needing the ReturnType
workaround. Here’s what the classes look like, annotated with the @injectable()
and @inject()
annotations that InversifyJS requires.
import { injectable } from 'inversify';
@injectable()
export class TimezonesService {
#timezones;
constructor() {
this.#timezones = moment.tz();
}
get timezones() {
return this.#timezones;
}
findTimezone(name) {
return timezones.find(tz => tz.name === name);
}
}
import { injectable, inject } from 'inversify';
import { TimezonesService } from 'timezones.service';
@injectable()
export class UserTimezoneService
constructor (@inject(TimezonesService) timezones) {}
findForUser(userZone) {
return this.timezones.findTimezone(userZone);
}
}
With the services re-written as proper classes, they now need to be registered with the InversifyJS container:
import 'reflect-metadata';
import { Container } from 'inversify';
import { TimezonesService } from 'timezone.service';
import { UserTimezoneService } from 'userTimezone.service';
// Defaulting defaultScope to Singleton mimics the behavior of AngularJS injection
export const container = new Container({ defaultScope: 'Singleton' });
container.bind(TimezonesService).toSelf();
container.bind(UserTimezoneService).toSelf();
These services can now be injected into React code with the useInjection
hook. And the magic of getting them recognized by AngularJS turns out to be surprisingly simple: register them as simple constants.
import { container } from 'inversify.config';
import { TimezonesService } from 'timezone.service';
import { UserTimezoneService } from 'userTimezone.service';
angular.module('Timezones', [])
.constant('timezoneService', container.get(TimezonesService))
.constant('userTimezoneService', container.get(UserTimezoneService);
Work for the future
The benefit of the above approach is that we can migrate slowly and pull out AngularJS injection in a single swoop by removing all the module files when no AngularJS components remain. The big downside is the need to rewrite all the services to classes which likely is a manual process (though maybe with some work it could be automated with jscodeshift). Another downside is that it maintains the anti-pattern of tying everything together which makes it hard to optimize page load time by splitting up modules and lazy-loading them. However, that’s a tradeoff that seems intrinsic to dependency injection, though that may be mitigated by registering interfaces and dynamically setting the concrete representation. Additionally, there is much we can do to move away from the AngularJS pattern of “inject everything” now that we have ES6 imports and many of these services don’t have any state and thus can be pure functions.
Cody Ray had a crazy idea that there may be a way to switch to InversifyJS with even less of a code rewrite! By overwriting AngularJS’ service
and factory
functions with custom implementations we could use InversifyJS instead. It would mean registering injectable classes dynamically and hanging on to the annotate
function that AngularJS uses to figure out injected arguments. Likely there are more complications, but if that ends up working expect a follow-up post 😉
[…] idea that the test should be clueless of what’s inside the box of the function under test. Moving from testing AngularJS code to React components this last year has brought this to light for me. Many of the tests for our AngularJS apps were for things like the […]
[…] web client was originally written in AngularJS, but we’ve slowly been converting components to React, a conversion our team has been mostly responsible for implementing. React’s approach to […]