There are many reasons to like React. It provides a nice library for writing reusable components and leverages its own virtual DOM, abstracting away the obtuse native DOM APIs in favor of a simple method calls, which are further abstracted away with a JavaScript language extension, JSX. Numerous other reactive and virtual DOM solutions exist that provide a similar level of abstraction. These solutions diff the current and next state and properties of components in the virtual DOM to determine if and where changes are needed in the actual DOM, resulting in a performant, testable abstraction for writing new components. React is also lightweight in that it is not a full application solution. It simply provides the framework for reusable components that can be plugged into any web application.
Web components
Web components are a set of standard APIs that make it possible to natively create custom HTML tags that have their own functionality and component lifecycle. The APIs include a custom elements specification, the shadow DOM, which allows the internals of a component to be isolated, and HTML imports, which describe how the components can be loaded and used in any web application. The main goal of web components is to encapsulate the code for the components into a nice, reusable package for maximum interoperability.
React
The focus of React and other virtual DOM implementations is to keep the application’s data in sync with the UI. It does this by having a unidirectional data flow and a lifecycle that will detect changes to the component’s state and properties, and then determine which parts of the UI need to update. This introduces a different way of thinking about how the application state is updated, as everything must flow from the top-down and the state of the component must be immutable. But what happens when we need to mix React and web components?
For example, suppose you already have a set of components created with another technology or framework that you would like to integrate and seamlessly use within a React application? For example, a web component custom elements library that is not written in React, but will be used by several applications, both those that use React and those that do not. Or perhaps the component already exists and it would take months of additional development to fully convert it to be a React component. In these cases, it makes sense to provide a React wrapper around the created component, but the following issues need to be handled.
- The React wrapper needs to provide access to all of the component’s attributes and properties as
props
on the React component. - The wrapper needs to provide a way to handle events that may be triggered by the underlying component.
- The wrapper needs to do its best to keep the component’s DOM in sync with React’s virtual DOM.
We’ll address each of these issues in the following sections.
1 Create a React Component
First, let’s introduce a simple Tabs React component which we will wrap.
<x-tabs>
<x-tab title="Tab 01">
<div>
<h3>Tab 01 Content</h3>
</div>
</x-tab>
<x-tab title="Tab 02">
<div>
<h3>Tab 02 Content</h3>
</div>
</x-tab>
</x-tabs>
This is a simple Tabs component (full source) using version 1 of the web components recommendation. The x-tabs
component will create an unordered list with each of the x-tab
titles that will control which tab has its content shown. As you can see, there is nothing in the markup that specifies where or how the ul
will be rendered, which makes this a good component to discuss as portions of the component are dynamically generated. This component has events that will be fired, dynamically generates markup within the component, and modifies its DOM structure.
2 Wrapping the component
The first step is to create the React wrapper for the tab and tabs components. The tab wrapper will just pass along the necessary props, so it can be written as a stateless functional component to satisfy our first requirement.
export const Tab = props => >
<x-tab {...props}>
{props.children}
</x-tab>
);
Tab.propTypes = { title: PropTypes.string, closable: PropTypes.bool };
export default Tab;
Since the Tab component does not emit its own events or otherwise modify the DOM, this wrapper is complete as-is.
3 Handling component events
The Tabs component gets a bit more complex as the underlying component modifies the DOM structure to generate the tabs list and to control which tab is currently active, and the component emits a tabclosed
event when a closable tab is closed. The React wrapper will need to listen for this event and then call an optional onTabClosed
method.
export class Tabs extends Component {
static propTypes = {
onTabClosed: PropTypes.func
};
componentDidMount() {
this.component.addEventListener('tabclosed', this.onTabClosed);
}
componentWillUnmount() {
this.component.removeEventListener('tabclosed', this.onTabClosed);
}
onTabClosed = ({detail: component}) => {
this.props.onTabClosed && this.props.onTabClosed(component.getAttribute('tabId'));
};
_handleRef = (component) => {
this.component = component;
};
render() {
return (
<x-tabs ref={this._handleRef}>
{this.props.children}
</x-tabs>
);
}
}
To test this, we can create a simple example application that renders the Tabs
component and provides an onTabClosed
property.
ReactDOM.render(
<Tabs onTabClosed={() => console.log('Tab Closed!')}>
<Tab closable={true} title="Test tab">Hello World</Tab>
</Tabs>,
document.body);
Clicking the close button on the tab title will result in the tab being removed from the DOM and effectively closed. However, you will quickly notice in the React Devtools that React still believes that the tab exists. If we were to do anything that would trigger a re-render of the component, we will receive an error.
4 Keeping the Virtual DOM in sync
This error occurs because the virtual DOM is no longer synchronized with the actual DOM. When React renders a component, it generates an object to represent the DOM. This object consists of three keys: tag
, attrs
, and children
.
{
"tag": "x-tabs",
"children": [{
"tag": "x-tab",
"attrs": {
"closable": true,
"title": "Tab 05",
"tabId": 4
},
"children": {
"tag": "h3",
"children": [
"Tab Content ",
"05"
]
}
}]
}
tag
provides React with the type of element will be created in the actual DOM. attrs
is an object containing the key-value pairs for each of the attributes that will exist on the element. children
contains all of the sub-elements and their attributes and children. Because React did not remove the x-tab
when the close button was pressed, React was not able to update this structure. In fact, nothing happened as far as React was concerned so React did nothing to re-render the component. When this happens, it will cause an error. React will attempt to remove the existing nodes in order to re-render that component, but since they have already been removed, it will fail.
A component’s rendering cannot be controlled by both React and by itself. In situations like this, it is necessary to prevent React from actually performing any rendering after the initial render
call for that component or component piece. To do this, we can simply provide React with a span
tag that will not be further modified by React. Now, React has a span
to keep track of in its virtual DOM, but it will not re-render that tag or any deeper within the span element as it is not aware of any changes that need to occur. To do this, we can change our render method to the following:
render() {
return (
<x-tabs ref={this._handleRef}>
{Children.map(this.props.children, tab =>
<span key={tab.props.title} ref={this._handleChildrenRefs}>
{tab}
</span>
)}
</x-tabs>
);
}
The tab was successfully closed, but it was closed outside of the render flow of the React component, so React’s virtual DOM has no indication that the component has been removed. By wrapping each Tab
in a span
, we have successfully prevented React from further updates to the component. This works great, but upon further inspection in the React Devtools, we can see that the tabs still do exist, according to React, even though they are not shown.
This is an unfortunate side effect of this approach, where there are still abandoned portions of virtual DOM, but in this case, it should not cause issues. There are scenarios where this could cause issues; for example, if the tabs themselves are controlled by React in an outer component that also provides a way to remove the tabs by updating the virtual DOM during rendering.
let keyVal = 0;
export class App extends Component {
constructor(props) {
super(props);
this.state = {
tabs: [
this.createTab({closable: true}),
this.createTab(),
this.createTab({closable: true})
]
};
}
createTab(props = {}) {
const index = ++keyVal;
const tabNumber = `${index + 1}`.padStart(2, '0');
return (
<Tab {...props} title={`Tab ${tabNumber}`} key={index}>
<h3>Tab Content {tabNumber}</h3>
</Tab>
);
}
addTab = () => {
this.setState({ tabs: [ ...this.state.tabs, this.createTab() ] });
};
removeTab = () => {
this.setState({ tabs: [ ...this.state.tabs.slice(1) ] });
};
render() {
return (
<div>
<button onClick={this.addTab}>Add Tab</button>
<button onClick={this.removeTab}>Remove Tab</button>
<Tabs>
{this.state.tabs}
</Tabs>
</div>
);
}
}
export default App;
This component uses the Tabs component and provides two buttons – Add Tab and Remove Tab. The component also begins with three tabs, two of which are closable, that are stored in the component’s state. If the first tab is closed by pressing the the close button on the tab, and then the Remove Tab button is pressed, nothing will happen visually. Because the first tab was closed outside of React, there still exists an invisible span
, which React is controlling, and it is this node that is removed, giving the appearance that nothing happened. However, this will result in the state.tabs
array reducing by one, accounting for the removed span
when syncing the real and virtual DOMs. In this example, we have no way to reconcile the state.tabs
array and virtual DOM with the changes to the underlying component.
This is where things can get tricky, as it can be difficult to map DOM changes back to the virtual DOM counterparts when subverting React’s rendering, and it is not something that can be handled directly from within the Tabs wrapper. Instead, the wrapper can provide information to component that it can use to reconcile the state such as a tab identifier. For the example, we can alert the component of the changes to a tab’s state by providing an event callback when a tab is closed.
5 Mapping events to callbacks
As we have already seen, the Tabs wrapper sets up an event listener on the underlying tabs component to listen for tabclosed
events to be fired. This event provides the x-tab
element that was closed on the event object, so that the wrapper can map this back to the JSX that was used to generate the tab. In the React component, we only have access to the JSX and how to create each of the tabs provided in state.tabs
. We do not have the exact reference to the tabs, only the formula used to create them. Instead, we can enforce the creation of a tabId
property that each tab must have. By ensuring that each tab has a tabId
property in its JSX, we can ensure that it is a property that we can search for to match the closed tab with the corresponding JSX.
onTabClosed = tabId => {
const tabs = this.state.tabs.filter(tab => tab.props.tabId != tabId);
this.setState({ tabs });
};
This method can be passed as the onTabClosed
prop to the Tabs component, where it will be called with the tab’s tabId
as its only argument. This method will call the setState
method and provide an updated tabs array that is missing the recently closed tab that was filtered out by its id.
Conclusion
It is not impossible to take a non-React component, wrap it inside of a React component, and use it as if it were a genuine React component. However, it becomes more problematic if the underlying component is modifying the DOM on its own, especially when nodes are removed. If the component is simple enough to be written in React, that will be the simplest option within a React application, but it is not always realistic. When a wrapper needs to be created, make sure to account for all properties, callbacks, events, and rendering scenarios to ensure the greatest compatibility.
Web components offer substantial potential for component interoperability across frameworks. It is our hope that all modern frameworks will eventually make it easy to import and export web components in a manner that makes it easy to leverage components in many different modern application contexts.