Using React and Web Components Together
Published • Last Updated • 3 minutes
If you have been tuning in, you might have noticed that I have a passing interest in Web Components and what makes them tick. Professionally, however, I primarily use React to build complex websites. So, how would one merge these two worlds without causing too much disruption?
The Plan
For this, we are going to use a tried and tested example: the "button with a counter". Below is an example of a "button with a counter" Web Component from my Let's Make Web Components Happen article (with slight modifications).
class InteractionExample extends HTMLElement {
count = 0;
button;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.button = document.createElement('button');
this.button.appendChild(document.createTextNode('Click me'));
this.button.addEventListener('click', e => {
this.count += 1;
this.button.innerText = `You clicked me ${this.count} times!`;
});
shadow.appendChild(this.button);
}
}
customElements.define('interaction-example', InteractionExample);
Now, to get this component to work in React, we have to do a few things.
- Import the JavaScript file directly into the file it's being used in.
import './web-components/InteractionExample';
- (Optional) Setup the TypeScript types.
declare namespace JSX { interface IntrinsicElements { 'interaction-example': {}; } }
Once we have those changes, we can use our Web Component directly in React!
import './web-components/InteractionExample';
function App() {
return (
<>
<interaction-example></interaction-example>
</>
);
}
export default App;
But wait, there is more. So, so much more.
Events From Web Components
We currently have a count
value in our Web Component, but we don't have a way of getting it out of the Web Component and into React. We can fix this by firing an event similar to an <input/>
changed event when the count changes.
We do this by calling dispatchEvent
with a CustomEvent
from within our click handler.
this.dispatchEvent(
new CustomEvent('update-count', {
detail: this.count
})
);
In React, we need to set up a couple of things.
We need a reference for the Web Component. To do this, we import
useRef
from React and add it to the Web Component in the JSX.import { useRef } from 'react'; import './web-components/InteractionExample'; function App() { const ref = useRef(null); return ( <> <interaction-example ref={ref}></interaction-example> </> ); } export default App;
With that reference, we can now set up the event listener.
useEffect(() => { if (!ref.current) { return; } const updateCountHandler = (e: CustomEvent<number>) => { console.log(e.detail); }; ref.current.addEventListener('update-count', updateCountHandler); return () => { ref.current?.removeEventListener('update-count', updateCountHandler); }; }, []);
If you are using TypeScript, you might nave noticed some errors due to invalid types. Let's sort those out.
The first error is regarding the ref=
{ref}
attribute on the Web Component. This is because the JSX type is missing the correct attribute declaration. We can solve this by editing our type declaration and adding an optional ref
attribute with the React type of RefObject
.
declare namespace JSX {
interface IntrinsicElements {
'interaction-example': {
ref?: React.RefObject<HTMLElement>;
};
}
}
The other error is regarding the ref.current.
addEventListener
and ref.current?.
removeEventListener
method calls. This is because our updateCountHandler
method has a CustomEvent<number>
type, but that's not the default for event listener methods.
We can correct this by adding a type that extends HTMLElement
.
type InteractionExample = HTMLElement & {
addEventListener(
type: 'update-count',
listener: (this: HTMLElement, ev: CustomEvent<number>) => any,
options?: AddEventListenerOptions
): void;
removeEventListener(
type: 'update-count',
listener: (this: HTMLElement, ev: CustomEvent<number>) => any,
options?: AddEventListenerOptions
): void;
};
Once the types have been updated, we can go back to React and update the reference we have to point to for the new type.
const ref = useRef<InteractionExample>(null);
So now that we have all of that setup, we can finally show the results on the page and not just in the console.
We do this by adding a useState
variable to store the count value and render it alongside the Web Component button.
import { useEffect, useRef, useState } from 'react';
import './web-components/InteractionExample';
function App() {
const ref = useRef<InteractionExample>(null);
const [count, setCount] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
const updateCountHandler = (e: CustomEvent<number>) => {
if (e.detail !== count) {
setCount(e.detail);
}
};
ref.current.addEventListener('update-count', updateCountHandler);
return () => {
ref.current?.removeEventListener('update-count', updateCountHandler);
};
}, []);
return (
<>
<h1>
Count: <code>{count}</code>
</h1>
<interaction-example ref={ref}></interaction-example>
</>
);
}
export default App;
Events From React
So we have data flowing from the Web Component back to React, but we don't have a way of sending data back the other way.
First things first, we need to be able to set the count with an attribute on the Web Component. We do this by adding two things to our Web Component:
Overriding the static property
observedAttributes
in our Web Component class.class InteractionExample extends HTMLElement { static observedAttributes = ['count']; }
Overriding the
attributeChangedCallback
method.attributeChangedCallback(name, oldValue, newValue) { if (name === 'count') { const parsedValue = parseInt(newValue, 10); if (!Number.isNaN(parsedValue) && parsedValue !== this.count) { this.count = parsedValue; this.button.innerText = `You clicked me ${this.count} times!`; } } }
Then, we update the type declaration to include the new attribute.
declare namespace JSX {
interface IntrinsicElements {
'interaction-example': {
ref?: React.RefObject<HTMLElement>;
count?: number;
};
}
}
Now, we can set the initial value of the count in React using our state value and include a button to change the count value in React.
return (
<>
<h1>
Count: <code>{count}</code>{' '}
<button onClick={() => setCount(count => (count += 1))}>+</button>
</h1>
<interaction-example count={count} ref={ref}></interaction-example>
</>
);
Final Result
You can try out the final result on CodeSandbox.