The woes of using React2Angular mix ReactJS components into my AngularJS App
NOTE: This is a WIP Post
I've spent the last few weeks working with writing ReactJS components into my AngularJS App using React2Angular and I want to share some of my findings.
First I'll show you what React2Angular does for you. It allows you to take ReactJS Components, and plug them directly into your AngularJS code as AngularJS Components. Take a look at the following code.
CodeExample1
- On line 12 I'm creating a new AngularJS module.
- On line 13 I'm requiring some modules it depends on. They're some API services.
- On line 15 I'm using
react2angular
to turn a ReactJS componentEditContractContainerReact
into a new AngularJS componenteditContractContainerAngular
and attaching it to my module. The 2nd parameter defines any props that can be used on my component, and the 3rd parameter defines what AngularJS services I'd like to inject into the component as new props. So if my component needs access to $http, $stateParams, or any of my own custom services (userService
) I can pass them to the component. You can also see I'm passing in the$injector
but I'll talk about that later. - The rest of the file is boilerplate AngularJS stuff to setup a route to point to my new React2Angular component.
*Note: Once you use react2angular you end up with an AngularJS component but since they're special in a couple ways I'm going to refer to them as React2Angular components. *
Aside: If you're interested in how you write Unit Tests for these components check out Unit testing react2angular components with Karma Jasmine and Enzyme
Once you turn them into React2Angular components you can use them in your code just like you would any other AngularJS component... kind of.
At first I was very excited about this library and what it could offer but as I started to use it more and more I started running into it's limitations. The limitations are egregious enough that I now have concerns that it's not really a path worth taking and I'll explain those below.
Template Structure
First I'll talk about the HTML/Template structure limitations.
Some of the code structure limitations I've run into include:
- Once you go ReactJS, there's no coming back. You cannot nest AngularJS components inside of your React2Angular or ReactJS components. You can't go
Angular -> React -> Angular
. This means you won't be able to re-use all of the AngularJS shareable components/directives/UI you've written. I ran into this when I wanted to re-use this "File Upload Widget" that we had written in AngularJS. I plugged in the component and it just wasn't rendered to the page. I believe the only way to have truly shareable components in this format is to re-write all of my AngularJS components as ReactJS components. Then I can use them directly in ReactJS, or in my AngularJS by using React2Angular. - Transclusion or usage of
props.children
are not supported at the React2Angular component level. The children ReactJS components of your React2Angular component can begin usingprops.children
, just not your top level React2Angular component. Considering how often I useprops.children
in my ReactJS components I feel that this is a pretty big deal.
Injecting Services
In my code example above you see on line 15 I'm injecting userService
into my component. This feature of React2Angular allows us to inject not only the AngularJS built in services like $http
, $stateParams
, etc but also all of our own AngularJS Services that we've written.
This is extremely important because AngularJS has it's own unique DI (Dependency Injection) which prevents us from simply requiring that userService directly. I'll explain why.
A typical AngularJS service factory is defined like this:
CodeExample2
If you look at what we're exporting, it's not the code itself, but a name of the module that the code is attached to. So if I were to do import UserServiceApiMod from '../../user-service.js';
I'm going to get a string of myMod.userService.api
. Now the only way to get access to that code is to include that string as a requirement of another block as seen on line 13 in CodeExample1. This is fine when we're working in an Angular component because we're always working within the context of a module where our dependency can be included.
So, in order to get our userService
accessible inside of our downstream ReactJS component we have to include it as a requirement in the module where we attach our React2Angular component. Then we have to pass the individual service name in the 3rd parameter CodeExample1 - Line 15. Once done, in our React component we can make use of this Service which is passed in as a prop.
CodeExample3
I think this is a really great feature to be able to inject services into our ReactJS components but it also is going to lead to some really messy code and I'll show you why.
Consider the following example:
CodeExample4
What if a ReactJS component nested 4 levels deep requires userService
. It can't inject it directly so it must be passed down. So we'd have to go back up to the file where the React2Angular component is first defined and inject our userService
. Then we have to pass it down through all of the intermediate components between the top and the component that needs it.
CodeExample5
To me this is starting to look pretty nasty...
- Consider doing this for 10 children, with 10 separate dependencies.
- Now our intermediate components are getting cluttered with props they shouldn't be concerned with.
- We can't refactor this to be more flat by having all of these components be nested as
this.props.children
of our primary component since transclusion isn't supported. - We can't easily move the code around or use this component in a different place without having to go update all of it's parents to pass down the dependency.
So what options do we have?
Make them all React2Angular components?
My initial thought was wrap every ReactJS component as an Angular2React component. This has the initial benefit that every react component gets it's own AngularJS module & therefore it's own requirements and DI. But this falls flat on it's face due to a limitation that I listed above which is that you can't nest React2Angular or AngularjS components inside of React2Angular/ReactJS components. So basically we'd be unable to have any component nesting.
Pass $injector?
My 2nd thought was to inject
$injector into the React2Angular component. The $injector is an AngularJS service which is used to retrieve object instances as defined by provider. It has a .get()
method which is essentially your gatekeeper into the DI.
By passing in $injector it allows us to $inject any of the providers (modules/services/factories/filters/directives) that are registered to our module. So in CodeExample1 our module defined on line 12 will have the following providers:
- The component defined on line 15 is a provider.
- All of the providers added to to all of the modules that were added as requirements of our module passed in on line 13.
One of these providers that's included is the userService
. So using $injector
we can get a reference to the userService
inside of our react component just like this:
CodeExample6
The thought behind using $injector
is that instead of potentially having to pass down the component hierarchy every single service that any child would need we can pass a single prop which can be used to get access to every one of those services.
This still isn't great because...
- The module where the service is defined still must be "required" somewhere in your AngularJS dependency injection. For instance in CodeExample6 line 8 I'm trying to $inject
accountService
but I forgot to include the include & require the module where that service is defined on CodeExample1 line 13. This will result in aaccountService Provider not found
error. This requirement means that if you were to begin using a downstream component that was going to $inject a service you'd have to remember to maintain that list of included modules in your top react2angular component definition. This screams Fragile Code and will undoubtedly lead to including services/modules that are not even used downstream. - We still have to pass a prop (
$injector
) around that intermediate components may not even care about.
I came up with a solution for the 2 issues listed above but it's kinda hairy.
service_exposer.js
lines 7-15
- The file automagically crawls through the current directory and finds all of the services files excluding itself and creates an array containing all of the module names (that are the exports of those files)
line 19-23
- It loops over all of those modules and gets the name of the service which is defined on each of those and adds it to an array of servicesToExpose.
line 26
- It creates a new angular module which adds all of those modules as requires.
lines 29-36
- It adds a run function to the module which will get executed when AngularJS has bootstrapped the app. This function loops over all of those serviceToExpose and uses $injector to get the underlying code. Then it attaches that service as to the serviceExport object where they key is the name of the service and the value is the object containing the service methods.
line 38
- It attaches the name of the module as the name
property. This is used to be able to inject it in my top-level angular module.
line 40
- It exports this ServiceExport object so that others can include it.
So to use this file you first drop it into the services directory of all of the services you wish to expose. Second you include this module in your top-level AngularJS module like below.
Now you're able to include these services directly into your React2Angular & ReactJS components.
This means that:
- We no longer have to pass $injector around to get access to any of our custom services.
- We no longer have to remember to include the modules for the services as requirements to our top level React2Angular file.
- We still have to pass in $injector for angular specific services like $http, $timeout, $stateParams.
That last point is such an annoyance that it makes me think all that madness isn't even worth it. If we still have to pass $injector around in case someone needs access to AngularJS services, what's the point?
There's 2 directions I could head regarding this issue:
1. Use ng-import for injecting AngularJS services
I could use the library ngimport which exposes imports for $http, $log, and other Angular 1 services. I'm not going to head down this path for a couple of reasons:
- Not all AngularJS services are supported like $cookies, $animate, $routeParams. So I'm still going to end up needing $injector.
- The project seems to be dead and I'm not having an easy time getting responses about the code.
- I feel like I'll just end up running into morea problems trying to make this work.
2. Use $injector For Everything
I could delete lines 29-35 of the service-exposer.js
and still include it in my top level angular module though. Then at least I would not have to worry about always remembering to add the modules to the top level react2angular component when my downstream react components needed access to a service. And I could use $injector for all services, both angular and those custom services in my /services/
directory. Then at least we don't have the confusion of "Inject native angular services like THIS but inject services from XYZ directory like THIS".
But I still have that issue of having to pass a reference to $injector
all over as props. And that's still REALLY bothering me. Is there a way to make a prop available to all of my ReactJS components so I don't have to clutter them all up with passing a prop down the tree entire? **Enter ReactJS's Context API **
React Context API
In a typical React application, data is passed top-down (parent to child) via props, but this can be cumbersome for certain types of props (e.g. locale preference, UI theme) that are required by many components within an application. Context provides a way to share values like these between components without having to explicitly pass a prop through every level of the tree. I honestly don't know if this is going to solve my problems 🤞but I'm going to give it a try and see what happens.
To be continued..