Search code examples
javascripthtmlreactjsonclickinnerhtml

How to add onclick event to a string rendered by dangerouslysetInnerHtml in reactjs?


I have a string , i.e,

let string= "Hello <b>Click here</b>";

render() {
return (<div dangerouslySetInnerHTML={this.createMarkup(value)}/>
}

createMarkup = value => { 
        return { __html: value };
 };

What I would like is to be able to add an onclick event to the <b> tag perform state manipulations on click.

The underlying problem is where I had a function which was supposed to render whatever is passed by the API. The API would send a string 'Money received for order ID 123', or could be any string that I have no control over. Later, I got a requirement where the item that is bolded must be clickable, so as to perform some actions. I didn't have any other way to solve it.

How can I achieve this?


Solution

  • Caveat: This sounds like an X/Y problem, where the underlying problem (whatever it is) should be solved differently, so that you don't have to add a click handler to a DOM element created via dangerouslySetInnerHTML (ideally, so you don't have to create DOM elements via dangerouslySetInnerHTML at all). But answering the question you asked: (You've clarified the use case; solution #1 below applies and isn't poor practice.)

    I don't think you can do that directly. Two solutions I can think of:

    1. Use delegated event handler on the div: Add a click handler on the div, but then only take action if the click passed through the b element.

    2. Use a ref on the div, and then hook the click handler up in componentDidMount and componentDidUpdate (finding the b element within the div via querySelector or similar), something along these lines:

    Here's an example of #1:

    <div onClick={this.clickHandler} dangerouslySetInnerHTML={this.createMarkup(string)}/>
    

    ...where clickHandler is

    clickHandler(e) {
        // `target` is the element the click was on (the div we hooked or an element
        // with in it), `currentTarget` is the div we hooked the event on
        const el = e.target.closest("B");
        if (el && e.currentTarget.contains(el)) {
            // ...do your state change...
        }
    }
    

    ...or if you need to support older browsers without ParentNode#closest:

    clickHandler(e) {
        // `target` is the element the click was on (the div we hooked or an element
        // with in it), `currentTarget` is the div we hooked the event on
        let el = e.target;
        while (el && el !== e.currentTarget && el.tagName !== "B") {
            el = el.parentNode;
        }
        if (el && el.tagName === "B") {
            // ...do your state change...
        }
    }
    

    ...and where you bind clickHandler in the constructor (rather than using a property with an arrow function; why: 1, 2):

    this.clickHandler = this.clickHandler.bind(this);
    

    Live Example:

    let string = "Hello <b>Click here</b>";
    class Example extends React.Component {
    
        constructor(props) {
            super(props);
            this.state = {
                clicks: 0
            };
            this.clickHandler = this.clickHandler.bind(this);
        }
    
        clickHandler(e) {
            // `target` is the element the click was on (the div we hooked or an element
            // with in it), `currentTarget` is the div we hooked the event on
            // Version supporting older browsers:
            let el = e.target;
            while (el && el !== e.currentTarget && el.tagName !== "B") {
                el = el.parentNode;
            }
            if (el && el.tagName === "B") {
                this.setState(({clicks}) => ({clicks: clicks + 1}));
            }
            // Alternative for modern browsers:
            /*
            const el = e.target.closest("B");
            if (el && e.currentTarget.contains(el)) {
                this.setState(({clicks}) => ({clicks: clicks + 1}));
            }
            */
        }
    
        createMarkup = value => { 
            return { __html: value };
        };
    
        render() {
            const {clicks} = this.state;
            return [
                <div>Clicks: {clicks}</div>,
                <div onClick={this.clickHandler} dangerouslySetInnerHTML={this.createMarkup(string)}/>
            ];
        }
    }
    
    ReactDOM.render(
        <Example />,
        document.getElementById("root")
    );
    <div id="root"></div>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

    Here's an example of #2, but don't do this if A) You can solve the underlying problem separately, or B) #1 works:

    let string = "Hello <b>Click here</b>";
    class Example extends React.Component {
    
        constructor(props) {
            super(props);
            this.state = {
                clicks: 0
            };
            this.divRef = React.createRef();
            this.hooked = null;
            this.clickHandler = this.clickHandler.bind(this);
        }
    
        clickHandler() {
            this.setState(({clicks}) => ({clicks: clicks + 1}));
        }
    
        hookDivContents() {
            // Get the b element
            const b = this.divRef.current && this.divRef.current.querySelector("b");
    
            // No-op if it's not there or it's the same element we have hooked
            if (!b || b === this.hooked) {
                return;
            }
    
            // Unhook the old, hook the new
            if (this.hooked) {
                this.hooked.removeEventListener("click", this.clickHandler);
            }
            this.hooked = this.divRef.current;
            this.hooked.addEventListener("click", this.clickHandler);
        }
    
        componentDidMount() {
            this.hookDivContents();
        }
    
        componentDidUpdate() {
            this.hookDivContents();
        }
    
        createMarkup = value => { 
            return { __html: value };
        };
    
        render() {
            const {clicks} = this.state;
            return [
                <div>Clicks: {clicks}</div>,
                <div ref={this.divRef} dangerouslySetInnerHTML={this.createMarkup(string)}/>
            ];
        }
    }
    
    ReactDOM.render(
        <Example />,
        document.getElementById("root")
    );
    <div id="root"></div>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

    Refs are an "escape hatch" giving you direct DOM access. Don't use refs lightly; usually, there's a better choice.

    But again: I would solve the underlying problem, whatever it is, differently.