Dependency Resolver (React MVVM Version 3 Proposal) #12
Andrei15193
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
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.
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 for 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 refer to an abstraction, i.e.: an interface.
We can resolve a dependency token and get an instance that implements the associated interface.
The description is only to help identify issues while debugging. The way we would use this is as follows.
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.
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.
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.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 theresolve
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 theresolve
method is being called, however we can combine it withuseRef
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.
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.
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 though 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.
Beta Was this translation helpful? Give feedback.
All reactions