After working for a relatively long while with React MVVM on a few projects to both test it out and use it to replace Redux Form, I’ve run into a pattern when it comes to creating instances of ViewModels, more notably the need to resolve dependencies.
Initially, I would resolve these dependencies in the component that creates the ViewModel. However, after doing this a few times it became obvious that there is a need for some kind of dependency resolver mechanism, similar to dependency injection that we use on the backend.
Why Dependency Resolver and Not Dependency Injection?
The difficulty stems from how JavaScript is, although I am using TypeScript on the projects where I use React MVVM, it is not enough to have a mechanism similar to dependency injection. I.e.: I specify the dependencies as constructor parameters and then the factory object that creates the instance for me will identify the constructors, the types of each parameter and simply provide the concrete implementations for me to use.
TypeScript does not have reflection like dotnet does meaning that it is not possible to simply identify the abstractions (interfaces) that a constructor requires and provide implementations for each. There is a way to somewhat reflect the TypeScript definitions, however, it can be a problem if it stops working as it would break the entire application. It’s too risky.
Instead of trying to make TypeScript, and consequently, JavaScript, work more like dotnet, it would be easier to use JavaScript as it is and provide a mechanism for resolving dependencies that would work natively, thus reducing the risk and the need for more libraries or other tools to get this to work.
Dependency resolution is a critical part of an application and it should be done reliably. This is best done using a dependency resolver object, than implementing a dependency injection mechanism when it comes to JavaScript, or TypeScript for that matter.
What this means is that types that do need to resolve dependencies, they will receive a dependency resolver through the constructor from which they will get all that they need. Not ideal as when we write tests we would need to check which dependencies need to be provided, however, it works even with native JavaScript making it more robust.
Where I want to get is to have a method which I can call to resolve a ViewModel.
const viewModel = dependencyResolver.resolve(MyViewModel);
class MyViewModel extends ViewModel {
private readonly _myService: IMyService;
public constructor(dependencyResolver: IDependencyResolver) {
super();
this._myService = dependencyResolver.resolve(MyService);
}
/* ... */
}
Type Resolution and Token Resolution
I’ve searched online for a few libraries or approaches that do this, some use a token to register dependencies, some use the class declaration itself to register dependencies, while most would use annotations.
The problem with annotations is that they are experimental and that they do not work like attributes in dotnet. Annotations tend to go towards aspect-oriented programming. On top of that, the dependency configuration would be all over the project instead of one place.
I want to combine the token and type declaration approaches as the former solves the problem of configuring and resolving abstractions, while the latter allows for impromptu resolution. Even if a class definition is not configured, we can still use the dependency resolver to get an instance.
Dependency Tokens and Abstractions
In TypeScript we can define interfaces which do not get in the JavaScript compilation result. This is a problem when resolving dependencies as there is no object that we can use to refer to this interface unless we create one.
Dependency tokens solve this problem by exposing an instance of a concrete type which is accessible in JavaScript and refers to an abstraction, i.e.: an interface.
We can resolve a dependency token and get an instance that implements the associated interface.
class DependencyToken<T> {
public constructor(description: string) {
this.description = description;
}
public readonly description: string;
public toString(): string {
return this.description;
}
}
The description is only to help identify issues while debugging. The way we would use this is as follows.
export const MyService = new DependencyToken<IMyService>("IMyService");
export interface IMyService {
/* ... */
}
// ...
// The service type is picked from the dependency token generic argument
const myService = dependencyResolver.resolve(MyService);
Type Resolution
As in the initial example where I resolve the ViewModel type, in most cases the instance I am resolving is transient. This means that each time I resolve a type or a token that is transient I will get the same instance inside the same React component instance during its lifecycle. However, for a different component instance, I will get a different resolved dependency instance.
function MyComponent(): JSX.Element {
const dependencyResolver = useDependencyResolver(); // A resolver is made available through a React Context
const { current: viewModel } = useRef(dependencyResolver.resolve(MyViewModel)); // Get an instance
// The above lines can be simplified by adding another React hook: useDependency(MyViewModel)
useViewModel(viewModel); // Subscribe for ViewModel changes
// The above lines can be simplified by adding another React hook: useViewModelDependency(MyViewModel)
return (
<>
{/* ... */}
</>
);
}
function TransientDependencyExample(): JSX.Element {
return (
<>
<MyComponent /> {/* Gets an instance of MyViewModel */}
<MyComponent /> {/* Gets a different instance of MyViewModel */}
</>
);
}
Dependency Configuration
The above example showcases how transient dependencies work, this would probably be most cases as most ViewModels are created by a root component, such as the details page level, and then passed down through props.
There is no need to configure this, concrete types can be resolved automatically as long as they follow some constraints which are applicable to any type registration.
- A public constructor without parameters, the type has no underlying dependencies, but it can be configured as a cache making it useful to have the same instance globally available (singleton configuration).
- A public constructor with one parameter, the dependency resolver, the type has underlying dependencies, but requires no additional data to create an instance.
For types that require additional dependencies, they need to expose a public constructor with any number of parameters with the condition that the first one is the dependency resolver. These types cannot be configured as the additional dependencies are unknown.
Depending on the case, a dependency token can be created and in the configuration to bind it to a factory callback where the additional dependencies are provided. This is typical for objects that require an application-wide config, such as logging or API endpoint information.
A type with additional dependencies can only be transient, and they are passed to the resolve method. Whenever one of the additional dependencies changes a new instance is created, similar to how deps
work for React hooks.
// Get a new instance whenever the entityId changes
const viewModel = useDependency(MyViewModel, [entityId]);
// Already has the entityId, we can load the information without any extra information.
viewModel.loadAsync();
Transient Dependencies
These have been covered through the examples that have already been presented, but I’ll reiterate.
A transient dependency is bound to the lifecycle of the component that resolves it. This ensures that during subsequent renders of a component, the same instance is returned.
This is done exclusively through a custom React hook, useDependency
. Calling the resolve
method directly on the dependency resolver, even from the same component, will return a new instance! The dependency resolver has no way of knowing from where the resolve
method is being called, however we can combine it with useRef
to ensure we resolve only once per component lifecycle.
Transient dependencies need only be configured for token bound configurations, it is the default when resolving types.
Scoped Dependencies
A scoped dependency is bound to the lifecycle of a dependency resolver scope which can go beyond the lifecycle of usual components.
Types and tokens configured as scoped will be created only once for that scope. This is useful for caches and delayed initialization as sometimes we may need a list of additional entities that only make sense for a particular page or set of pages.
For instance, if we can upload documents through a modal, we want to be able to dismiss the modal and if we want to upload another document, we do not want to resolve the list of additional items once again. We have not left the details page, we should be able to cache this list.
This would be a use case for scoped dependencies where the ViewModel that resolves this list is configured as scoped, we will get one instance throughout our editing session meaning that each time we show and dismiss the document upload modal we will get the same instance. We load the document types once and then reuse the list if we upload another document.
Once we save our changes, the scope is dismissed alongside all scoped instances that were created and thus the cache is invalidated. Next time we enter the edit session, we would get a fresh ViewModel that resolves our document types.
function EditPage({ entityId }: IEditPageProps): JSX.Element {
const editViewModel = useViewModelDependency(EditViewModel, [entityId]);
useEffect(
() => {
editViewModel.loadAsync();
},
[editViewModel]
);
return (
<DependencyResolverScope deps={[entityId]}>
{/* ... */}
<MyEditComponent /> {/* Gets an instance of MyViewModel */}
<MyEditComponent /> {/* Gets the same instance of MyViewModel */}
</DependencyResolverScope>
);
}
function MyEditComponent(): JSX.Element {
// MyScopedViewModel is configured as scoped
const myScopedViewModel = useViewModelDependency(MyScopedViewModel);
return (
<>
{/* ... */}
</>
);
}
Something to keep in mind is that scoped dependencies do not cross over to parent scopes, each scope is seen as independent in all cases.
const dependencyResolver = useDependencyResolver();
const childScope = dependencyResolver.createScope();
const grandchildScope = childScope.CreateScope();
const dependencyFromParent = dependencyResolver.resolve(MyScopedDependency);
const dependencyFromChild = childScope.resolve(MyScopedDependency);
const dependencyFromGrandschild = grandchildScope.resolve(MyScopedDependency);
All of the above resolved dependencies are distinct, one instance for each scope regardless of how the scope was created.
Singleton Dependencies
This is probably one of the simplest to explain. Dependencies configured as singletons are unique throughout the lifecycle of the root dependency resolver. This is similar to having a global state.
Singleton dependencies cross over scope boundaries and contained at the root dependency resolver thus they cannot be discarded unless an entirely new dependency resolver is discarded.
This is useful for caches and other configurations that need to be maintained throughout the application, such as the page where a user was when they navigated from a list view or the filters they configured on said list view.
Whenever the user navigates away and goes back to that list view, they need to be in the same spot as when they left it, this is done through a singleton as regardless of what other scopes are created and what dependencies are resolved, this one must remain the same.
Closing Thoughts
As mentioned in the beginning, after working for a while with React MVVM, the need for a dependency resolution mechanism became apparent. There are multiple ways of doing this, one is through dependency tokens to resolve abstractions, and another is through the type declaration itself. In JavaScript, a class is an object itself that we can reference and use.
There are 3 lifecycle options to configure dependencies, transient (default), scoped (semi-global, a mini ‘singleton’ that gets discarded when navigating away from a part of the application), and singleton (global dependencies that live as long as the application does).
Resolving dependencies is done through custom React hooks inside components, each dependency can have other underlying dependencies that are resolved in the constructor. Additional dependencies can be passed through the constructors and are provided when resolving a type.
While scoped dependencies are common on the backend and are generally tied to the processing of an HTTP request, on the frontend side, scoped dependencies are rather tied to a page or section of the application that the user is currently viewing.