The reasons for this decision were:
1. I had an interest to learn ReactJS
2. I felt I only needed a client side solution and not a full MVC stack.
Unfortunately, I don't know ReactJS, but how hard could it be, right?
To be honest, I found it fairly natural. I had a few basic questions that I thought if I could answer I would be on my way.
- How to create a ReactJS component?
- How to connect ReactJS components into an application?
- How to serve a ReactJS application from RoR?
- How to load data from the server and update a ReactJS component's state with that data?
- How to update a ReactJS component's state with data?
Before I could start answering these questions I had to decide whether I would use the Rails asset pipeline to build my ReactJS application or should I build the app using a separate build "eco-system" inside or outside my Rails application.
I had read suggestions that I should keep my client build system separate from my server build system since npm packages would be more up to date than their counterpart Ruby gems, and some JS libraries I might want to use wouldn't even be implemented as Ruby gems.
I contemplated this for a while, but in the end I decided that even though it seems cleaner to keep the build systems separate, I really don't know NodeJS well enough to develop a production level application nor do I have the time right now to get to that level of expertise. I do need to delve into that arena, in the future, but not right now.
So I decided I would see how far I could go with just leaning on the standard RoR tool chain.
This turns out to be a MAJOR decision. I did find a few links on making this decision. One of the best discussions of the different alternatives, I found, was a blog post by Blaine Hatab (link here).
Using Blaine's classification I chose what he calls method 1. In his post, he turned this method down as he wanted to do server side rendering. My goal was to avoid server side rendering by serving up the client in one call and then have the client make AJAX calls to the RoR application for it's data.
With that decision made, I was ready to tackle my list of questions to get going.
How to create a component?
There are plenty of tutorials on how to do this so I won't go into details about this. The big decision I had to make for this step was should I use JSX or JavaScript.
If I chose JavaScript then I had the choice of straight JavaScript or CoffeeScript (since it is baked into RoR).The tutorials I found were a little bit of both.
In the end I chose to use JSX for my ReactJS components and CoffeeScript for any other code.
To make this work all I had to include was the 'react-rails' gem.
After the asset pipeline is run by RoR you end up with straight javascript files anyway, so it felt natural to use the JSX syntax for ReactJS components, since it looks a lot like HTML with javascript mixed in.
So on to the next question.
How to connect React components into an application?
This one turned out to be real easy. Since I was going for a SPA type application I needed to create the root component of the application and serve it via a view.
My top-level component looks like this (Note: I have had a wail of a time showing code in my blogging application, so for now I will just show images so I can get this post out, if anyone knows of a good client that actually works with blogger I'd appreciate it):
In the end I created a router via the 'react-router' gem but for this post I won't go into that. I'll talk about that in a later post.
From here all the other components of the application just hang off of this one. On to the next hurdle:
How to serve a ReactJS application from RoR?
This turned out to be easy as well. By using 'react-rails' all I had to do was connect up the main application from above. 'react-rails' comes with an easy way to do this so my index.html.erb looks like this:
Yep, it's a one liner. I also removed all the extra stuff from my layout as the ReactJS component was going to supply it all so my layout ended up looking like this:
Again, extremely simple. On to the final challenge:
How to load data from the server and update a component's state with that data?
This is where things got a little complicated. ReactJS components have 'state' and the idea is that they bind to this state in order to always keep it up to date. Additionally, a component can pass this state to child components which is then available to child components in their 'props' array.
I found several tutorials that used the ability to pass in properties in what I considered a bad way. For example they would pass in callback methods so that when an action was taken in the child component the parent's callback function (which was passed as a property to the child) would get called.
I didn't like this idea as it seemed to couple the components together very tightly. What if I create a panel component to show an object and want to use it somewhere else? Will I remember to wire everything up appropriately? Knowing me, probably not.
This led to a design pattern that has been espoused for ReactJS applications called Flux. To be honest when I first read about it, my first reaction was "why did anyone need to come up with a new pattern just to replace MVC?"
I definitely had an aversion of learning this new way of thinking.
I tried to go down the route of passing in properties and coupling with callbacks, as outlined above, but as I started to segment my components into what I felt was logical components it got unwieldy.
For example, in my application's main view I have a header component and two list components. In the first list component it shows a series of panels, one for each object in my application. In the second list component it shows children of the currently selected panel in the first list.
The main application was the only component that knew about both lists. In order to wire up the first list so that the second list would get updated when the selected panel in the first list changed, I would need to pass in a callback method to the first list that was owned by the parent component and then the parent component would communicate to the second list what to do.
It got even more complicated if actions in the second list affected the state of the selected panel in the first list. What I needed was an event system.
This is where the Flux design pattern came in.
So, reluctantly I started to learn about Flux.
The idea with Flux is the components are not dependent on each other. Instead they communicate by firing and reacting to events. Since Flux is a design pattern, there really isn't any code to install, so the implementation is left up to the developer.
I'm going to talk about how I implemented it, I'm sure others will have different opinions on how to implement it. Probably my way isn't even correct, but it is working for my needs so I am going with it.
Essentially the way I have it implemented is as follows:
- All state is stored in 'Store' objects. These are essentially singleton objects. Right now I have a SessionStore for session type objects (user's profile) and an AppStore for everything else. So if I load something from the server, say the list of objects to show in my first list component above, they are stored in the AppStore object, NOT in the list component's state object.
- When the list component mounts, it registers as a listener to the AppStore component. Specifically it registers for it's interest for the objects being loaded. An example would be:
Notice this is CoffeeScript. Like I said above, any JS class that isn't a ReactJS component I wrote in CoffeeScript for the succinctness and the safety that CoffeeScript affords.
A couple of things about this method. When a component registers as a listener it passes a key that represents itself (I use the component's display name) that way it can deregister itself when it unloads.
Also it passes in a callback method. This callback method is the magic. So the idea is when the event occurs the callback is called and the component then takes the appropriate action based on the event that occurred.
- If an action occurs in the component it calls a method on a ActionCreator class specifically for that event which is responsible for collecting any parameters about the action, packaging it up as an action and sending that action to a Dispatcher.
- The Dispatcher class acts as, well, a dispatcher and determines if the action is a server action, in which case the action is passed to a WebAPIUtils class which communicates with the server tier or a view action in which case it dispatches the action to the appropriate 'Store' object which in turn fires the appropriate event for the action that occurred.
At this point the circle is complete.
I realize this was a little vague so I'll finish this post with code from the example above. One important note about this before I start into it. This is not exactly how the code is implemented. I have removed calls to helper methods, usages of constants, and I sanitized the code to be more generic than the actual implementation.
First here is my list component. It's job is to show a list of objects retrieved from the server:
Note in the 'getInitialState' method the loading of the objects from the store class. Initially it is empty. Also note in the 'componentDidMount' method how the component attaches itself as a listener to the AppStore class. It's also important to look at the callback method that is registered when the component is added to the AppStore's listeners. This callback sets the state of the ObjectPanelList which will cause the render method to be rerun which in turn refreshes the view with the latest state.
Next let's look at the pertinent methods in the AppStore class.
As I mentioned earlier, this is a CoffeeScript file. Notice how the add/remove methods hide the event names from the caller and register/deregister the listener in an internal hash of listeners.
The important method is the 'objectsLoaded' method which gets called when the objects are loaded from the server.
Now when the application is loaded an event is fired to load up the objects. This is done on the parent component of ObjectPanelList. Here are the pertinent methods of that component:
Essentially when the component is mounted it calls an internal method to tell the ActionCreator to load the objects. One cool side effect is, it doesn't really matter whether this action completes before or after the ObjectPanelList renders. If it happens before the ObjectPanelList gets the correct list when it renders from the AppStore, if it happens after then the callback method the ObjectPanelList registered in the AppStore listeners is called and it picks up the right objects then.
The pertinent method of the ActionCreator class is as follows:
This is a class level method that packages up the action and calls the appropriate Dispatcher method. Here is one point I am not sure about. The ActionCreator (at least the way I have it implemented now) knows that this must be a server action. Another approach might be to have a handleAction method on the Dispatcher that encapsulates this knowledge.
The pertinent Dispatcher methods are as follows:
Again we find class level methods. The '@handleServerAction' just repackages the original action. I'm not sure that is the best approach but in the tutorial I was (loosely) following that was how I understood it to be implemented. Seems like an unneeded level of indirection.
Finally, here is the method on the WebAPIUtils class:
This is the final piece of the puzzle. An AJAX call is made to the server and when the response is received (hopefully successfully) the AppStore's 'objectsLoaded' method is called which in turn calls the attached listeners. One concern I have here is, should I have this call an ActionCreator method to put the action into the system. It would seem that might be the case as it would match the architecture when making the remote call. But at this point it seemed like it was unneeded. I'l need to monitor the code to see if that is a refactoring step I need to make.
Well there you have it. A complete round trip from the JSX components to the server and back. This avoids almost all coupling of components together and sets up an event system that can be used for both remote calls as well as inter app calls.
For example in another case I need to update a dependent list when an object in the first list is selected. This uses the same pattern, but with a different event type and there is no remote server call.
As I continue to scale out the features in this new app, I find this pattern to be holding up quite well, a and more importantly the code feels natural and well separated.
That's it for this week.
No comments:
Post a Comment