The rising interest in React inspired us to try implementing web components that use a reactive approach to rendering. As an experiment, we’ve redone a React tutorial in web components, using Redux for predictive state management, JSX for rendering state, and virtual-dom for comparative DOM updating. Essentially, we treat each web component as if it were a reactive application in its own right. Our goal isn't to push an agenda, but to learn what the reactive web component patterns might look like.
Facebook's React JavaScript library is very popular and has growing numbers of supporters and projects. It often comes up in our discussions with clients and others, some suggesting that they're "strictly a React shop," with the implied notion that web components and/or other approaches are incompatible with React components and the library in general. We think web components are compatible with React (and other frameworks), with some integration planning. Others have contributed to the thinking as well.
The React phenomenon has moved us to think more about the principles of reactive programming and how those principles may be applied to web application development in general, and in particular whether they could be employed in the encapsulated development of web components.
Stripping things down to bare essentials, we decided to try our hand at implementing web component internals with a React-like approach, using only Redux for predictive state management and virtual-dom for comparative template rendering of a virtual node tree to the DOM.
This post assumes you have familiarity with the web component specification and the basics of React — though we're not using React here. This series of lessons on Redux is a great way to get familiar with its state management flow. It is also useful to have a look at a couple of our earlier posts on building web components and mixins with ES2015 JavaScript.
This introductory React tutorial demonstrates how to build nested UI components in React in a simple, understandable way. It builds a comment box of the sort you might see following a blog post or news article. The comment box consists of a small number of React components which the tutorial guides you in building. The component structure of the comment box looks like this:
CommentBox --CommentList --Comment --Comment -- … --CommentForm
We'll follow the outline and structure of the tutorial in implementing a similar set of web components, and we'll generally follow the progression of the tutorial so you can compare the steps taken here with the React component approach. They're very similar approaches, but we are emphasizing the reactive programming model via Redux and virtual-dom rather than demonstrating the wrapping of React techniques. We want to emphasize the immutable state and virtual dom rendering techniques, usually seen applied at the application level, encapsulated within the implementation boundaries of web components.
You should also note that as we are following the outline of the React tutorial, our components will render to their shadow DOM tree, as opposed to constructing local DOM. If you're confused about local vs light vs shadow DOM, see this description. Keep in mind that shadow DOM is supported natively in a limited number of browsers, such as Chrome. We use the polyfill library, webcomponents.js, to provide shadow DOM behavior in browsers that don't otherwise support it.
If you want to jump ahead and see a running demonstration of the project, you can find it here.
The completed project can be cloned to your local environment. You can then pull down the npm dependencies and build the project following the directions in the link. The source code we'll be looking at is found in the src/scripts folder — CommentBox.js, CommentList.js, Comment.js, and CommentForm.js.
You can load the root index.html file under a local web server. The HTML is very simple:
Note that we load the transpiled script file, es5scripts.js, in line 8. The previous line loads the web components polyfill library. And line 19 is the entry point to our component code, with the use of the <rwc-comment-box> custom element.
First, we’ll note that we’re building our components as ES2015 classes, deriving each of our components from HTMLElement, and adding the AttributeMarshalling mixin as discussed in Jan’s post on mixins.
All of the components in this project implement a certain pattern which we'll illustrate with our outermost component, CommentBox. Each component begins with an implementation of createdCallback with a call to super. We will describe the use of the USING_SHADOW_DOM_V0 constant soon.
After defining the component class, we register the element with the DOM, and export it so it can be imported elsewhere in our project code. We have something like this for each of the four web components we’re building — CommentBox, CommentList, Comment, and CommentForm.
Now that we have a basic shell for each of our components, we’ll look at the common aspects in the use of Redux and virtual-dom. Each component has its own instance of a Redux store, including its own implementation of the store subscriber callback, reducer method, state object, and dispatch actions. Let’s sketch those in next.
We’ve added an import for createStore from the Redux module, defined a getter for the component’s immutable default state, set up the initial code for the component’s reducer method, and specified the Redux store listener. We’ve defined the reducer as a static class method for two purposes:
All state for the component should be maintained within the Redux store, and we organize our Redux code to help us remember reactive programming principles. We similarly define the default state getter as a static method. We’ll flesh out the default state object shortly.
We added Redux initialization code at the beginning of the component’s createdCallback method, prior to making the call to the component’s base class. We are treating Redux initialization as constructor code (as we will with virtual-dom initialization), and because as of today the browser will not call into our constructor, we treat the opening chorus of the createdCallback method as if it were the class constructor.
To this point, we’ve established the Redux flow in the component: Create the Redux store at initialization time (createdCallback) associating the static reducer method with the store, and subscribe to changes in state with the storeListener method which is bound to the class’s this object.
Next, we’ll add scaffolding for our use of virtual-dom.
First, we added imports from virtual-dom for its diff, patch, create, and h APIs. We then initialized the virtual node tree in createdCallback with a call to the component’s render method. The render method uses JSX which is transpiled into virtual-dom h() calls and returns a virtual node tree. Note that for now, the component’s JSX is a placeholder div. Continuing, we then set the DOM root node for the component’s intended shadow DOM with a call to virtual-dom’s create.
Thus far, this is very similar to setting up a <template> element for rendering to a component's shadow DOM. Instead of using <template>, we make use of JSX with the intention of rendering its virtual node tree to shadow DOM.
Now the use of the USING_SHADOW_DOM_V0 constant comes into play. We create a shadow root by using the version of the API supported by the browser, falling back implicitly to the polyfill library where no native support for shadow DOM exists. We then append the resulting virtual-dom/JSX DOM tree to the component's shadow root. We do all of this initialization of the Redux store and virtual-dom tree prior to calling our superclass, ensuring that our state management and rendering system is in place before any mixins begin their initialization.
Subsequent interactions and rendering will take advantage of virtual-dom’s ability to compare a new virtual node tree with the current one, and apply changes to the component's shadow DOM. While the virtual-dom documentation describes this process, what’s important to note here is that what is typically done on a full application UI basis is being done here only within the shadow DOM context of the component.
This is the key point. We can take a pattern for utilizing Redux and virtual-dom at the application level and encapsulate them, instead, within the context of a web component. What we have in place now is code common to any web component we write in this manner. In fact, an additional exercise which we won’t do here is to capture this common code in a new mixin which would be added by any reactive programming web component.
Let’s summarize the flow for all our components at initialization, noting that createdCallback is the entry point to the component’s code.
Interactions with the component cause steps 3 and 4 to repeat.
Here we’ve reached the equivalent point in the React tutorial where you begin composing components. The rest of the work depends on leveraging this architecture, implementing the semantic details of each component.
The React tutorial makes network requests for comment data, but we will simplify things here for illustration purposes, using mock data and local-only interactions. The CommentBox component will render an instance each of CommentList and CommentForm components as shadow DOM, passing comment data to CommentList, and watching for “comment-added” events from the CommentForm component. We’ll provide CommentBox with initial comment data, as if that data had been downloaded from a server. We do that in a method called initializeWithMockData. The changes to CommentBox’s initialization code look like this.
In createdCallback, we’ve added the call to initializeWithMockData, and we’ve added an event listener for “comment-added” which we’ve bound to a method called handleCommentAdded. The comment-added event will be dispatched from the CommentForm component. Note that initializeWithMockData dispatches an action to the component’s Redux store. We need to define what the component’s state looks like, and update the reducer method.
The CommentBox’s default state is an object holding an array, commentList, of comment objects. We’ve added a deepCopy method to the defaultState object that allows the object to be copied in an immutable fashion, remembering that the reducer method never modifies existing state.
In the updated reducer method, we’ve added support for the ADD_COMMENTS action. We take the current state as passed into the reducer method by Redux, copy that state to a new object, then push the array of comments specified in the action parameter onto the new state’s commentList array. We then return that new state.
What happens with that new state? Remember that we subscribed to changes in the Redux store with our storeListener method, so that gets called when we return the new state from the reducer method. It’s that state that then gets fed to our render method in order for virtual-dom to generate an updated DOM. We need to update our render method, which now looks like this.
We’ve added <rwc-comment-list> and <rwc-comment-form> to our JSX, corresponding to instances of our CommentList and CommentForm components. What’s important to note here is how the render method binds the new state to the updated UI of the component. The render method acts like a template rendering system. What we’ve done here is to pass the current state’s commentList as attributes to the CommentList component so that CommentList can construct its children Comment components. The CommentBox also hosts a CommentForm, for which we’ve added inline style just so we have some visual separation on the page.
Remember that we set up an event listener for add-comment events being dispatched from the CommentForm component. The event listener callback looks like this.
Here, we make use of the ADD_COMMENTS action once again, dispatching the action to the CommentBox’s store just as we did in initializing with the mock data.
At this point, we’ve completed the implementation for the outermost component, CommentBox. The remaining components have the equivalent flow, sharing similar code that might later be refactored into a mixin class. Let’s look at some of the details of their implementation, starting with CommentList.
CommentList uses its coment-data attribute as its communication mechanism with its hosting CommentBox (or, for that matter, any host). Its state object is identical to CommentBox’s, though its reducer is somewhat special in that it treats the attribute data as the exclusive specification for the new state, ignoring any previous state. Feed the CommentList an updated comment-data attribute, and it will render the comment data to a set of child Comment components.
What may not be obvious through viewing the code is that CommentList takes advantage of the AttributeMarshalling mixin we mentioned earlier. A change in the comment-data attribute results, via AttributeMarshalling, in a call to CommentList’s commentData property setter.
This property implementation should look interesting to anyone familiar with web component development, and is a key aspect to the reactive programming approach. Note that the property does not correspond to class state. There’s no data object corresponding to this.commentData. Instead, the property setter is a dispatcher to the CommentList’s Redux store instance — a store instance separate from each of the other component instances in our project. Similarly, the property getter retrieves data from the Redux store’s current state.
It’s worth looking at CommentList’s reducer to see how new state is generated.
And this is how CommentList renders changes to its state.
The render method creates an array of rwc-comment virtual nodes, each rwc-comment corresponding to an instance of the Comment class.
What we’re seeing here is a pattern of building web components using reactive programming techniques, each component holding its own instance of a Redux store and leveraging virtual-dom to specify its entire virtual node tree as rendering input for a difference-patched output. The pattern works at the individual component level, and works even with components nested within components. It’s no great surprise when you think about it, but it’s exciting at the same time to observe it in practice.
Let’s look into the implementation details for the Comment component. If you take a look once more above at the render method for CommentList, you’ll see that each comment has the form:
<rwc-comment attributes={{author: comment.author}}> <div id="commentText"> {comment.commentText} </div> </rwc-comment>
We are anticipating that the Comment component is able to manage and render a host-supplied child node tree, most likely through the use of the <content> element. In fact, that is exactly the case, as we'll soon show. The Comment component processes the author attribute, but the decision for how the comment text is to be rendered is up to the host of the Comment component. Here, the decision is to render it as text within a <div>.
From this interface, you might anticipate what the Comment component’s state object and reducer look like.
The Comment component’s state object is simple, containing a string value for author. The reducer method follows logically, with a single action, SET_AUTHOR. Note the use of Object.assign for merging the previous state with new data, and creating a new state object to be returned in an immutable manner. At this point, you might anticipate that the component has an author property, with a setter and getter that work against the Redux store rather than keeping state as member variables in the class object.
This follows the same pattern we used with properties for CommentList. Next, let’s take a look at Comment’s render method.
The shadow DOM sets up a <div id=”comment”> element containing an <h2> element whose text content is the comment author. Sibling to the <h2> element is a <content> element which will display the child node tree specified by the component's host. Remember from above that this is the content specified in CommentList's render method, between the <rwc-comment></rwc-comment> tags:
<div id="commentText"> {comment.commentText} </div>
As the Web Component specification evolves, the <content> tag will be replaced with the new <slot> tag and the way that content gets injected into the shadow DOM will change. That's just something to keep in mind for now.
All that is left now is the implementation of the CommentForm component, which manages a form element with input fields for author and comment text, and a submit button. The state object and reducer are straightforward.
The state object has values for author and comment text, with the default state setting both of these to empty strings. The reducer method supports three actions: setting author or comment text, and clearing the form. The latter sets the current state to the default state object.
Let’s look at the render method for CommentForm.
The author and comment text input fields get populated by their corresponding values in the current state, with the both of these elements triggering onchange events. The form element itself handles onsubmit. The event handlers depend on property settings that dispatch actions to the Redux store.
We can see in handleSubmit that we create and dispatch the comment-added event which the CommentBox component listens to. It also causes a CLEAR_FORM action to be dispatched to the Redux store, resulting in the input fields being cleared out.
The event handlers dealing with changes in the input fields use property setters to dispatch actions to the Redux store as well.
Finally, note the special selector mechanism we use to set the focus on the input element having id="authorInput".
this.$.authorInput.focus();
We get support for this syntax by adding the ShadowElementReferences mixin from basic-component-mixins. We're now adding two mixins to ComponentForm as it derives from HTMLElement. We need to import the appropriate modules, and we use the Composable helper to declare the class as shown here.
We've demonstrated how we can use a reactive programming approach to implementing web components. We treat a web component's implementation boundaries as if it were an application, with Redux and virtual-dom serving in the same manner in the component implementation as they would within a full application. Unlike in the React tutorial, the components we've created here, because they are web components, are usable outside the context of this tutorial.
We're interested in seeing what kinds of discussions this approach raises. There's a lot to explore here including from a perspective of usefulness of approach, performance issues, and unit testing.