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

4

21

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?

Broussard answered 9/1, 2019 at 12:7 Comment(4)
Why would you do that? What's the use case? It seems like an X/Y problem; if it's at all possible, it would be better to solve the underlying problem differently.Scrawl
Should value in this.createMarkup(value) be string?Scrawl
You can't do that. By setting HTML through dangerouslySetInnerHTML, you're leaving the React scope and you won't be able to call back to it, and even if you get it working, it will be ugly and probably risky. If you want something to be handled by React, do it using React. If you want your <b> tag to update the state, then, create it properly using React.Equipollent
In react returned elements should have at least one parent element. I would split "string" into two strings and <b> element should be as react element not as string.Hobnob
S
26

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.

Scrawl answered 9/1, 2019 at 12:15 Comment(11)
While this situation should really be solved in another way, I don't thing using refs is a great advice to give to a beginner in React as it is considered a bad practiceParturient
@Parturient - Refs aren't bad practice for the things they're for. But as I said in a comment on the question, and the beginning (and now end) of the answer, whatever the underlying problem is, it should almost certainly be solved a different way.Scrawl
@Parturient - Had a Doh! moment and added a much better solution. :-)Scrawl
Oh wow, that's a lot of code. While this answers the question perfectly, I don't think it will help the OP understand the React-like architecture and confuse him even more. I hesitated to post a more React-like way of doing this but it may go against SO's logic.Parturient
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 <b>123</b>', or could be any strings 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 do you think you would have handled it? @T.J.CrowderBroussard
@CKA - My gut reaction is twofold: A) I'd see if it was possible to get the information from the server instead of HTML, so this doesn't come up. If not possible, then B) I'd use a delegated event handler (solution #1 above). It's fairly clean for the situation described. But I think a new question outlining that situation and asking what the React way to handle it is would be a good question and I'd be keen to see answers to it. (But don't change this question, since changing questions once they've received answers in a way that invalidates the answers is not how SO works.) Happy coding!Scrawl
Thank you sir! I realize that this kind of situation shouldn't arise. But sometimes such strange requirements come up and sometimes we don't have a say in these which is unfortunate. But I'm really thankful for your insightful answer.Broussard
I think I figured out a more 'React' way of solve this specific problem considering the information you gaveParturient
@CKA - Oh absolutely. :-)Scrawl
clickHandler(e) { let el = e.target; if (el && el.tagName === "B") { // ...do your state change... } } I think this should suffice, shouldn't it? This works fine too.. @T.J.CrowderBroussard
listening for events on the parent is so brilliant and simple, great solution, thanksWheen
S
7

react-html-parser can convert HTML strings into React components.

using transform callback function you can update any tag in HTML string with JSX tag adding any properties and event listeners.

This is how I used it:

ReactHtmlParser(item.value, {
    transform: (node) => {
        if (node.name === 'a' && node.attribs && node.attribs.href) {
            const matched = node.attribs.href.match(/^activity\/([0-9]+)$/i);

            if (matched && matched[1]) { // activity id
                return <a
                        href={node.attribs.href}
                        onClick={(e) => {                                                            
                          e.preventDefault();                                                            
                          this.props.openActivityModal(matched[1]);
                        }}
                        >{node.children[0].data}</a>
            }
        }
    } 
})
Snip answered 4/5, 2020 at 23:16 Comment(0)
P
0

Here is a clean way to achieve your needs. By splitting your string depending on the <br> tag you can end up with an mappable array of text :

class BoldText extends React.Component {
	constructor(props) {
		super(props)

		this.state = {
			input: "Money received for order ID <b>123</b>, wow for real, so <b>cool</b> its insane"
		}
	}

	boldClick = ev => {
		console.log('clicked !')
	}

	render() {
		const { input } = this.state

		const a = input.split('</b>')


		const filter = /<b>.*<\/b>/
		const text = input.split(filter)
		const clickable = filter.exec(input)
//<b onClick={this.boldClick}></b>
		return (
			<div>
				<p>{a.map(t => {
					const [text, bold] = t.split('<b>')
					return <span>{text}<b onClick={this.boldClick}>{bold}</b></span>
				})}
				</p>
			</div>
		)
	}
}

ReactDOM.render(<BoldText />, document.getElementById('root'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.production.min.js"></script>
<idv id='root'>

This solution should solve the problem you mentioned in the comments of the answer above. You can put your API call in the componentDidMount lifecycle function and change your state from there.

Parturient answered 9/1, 2019 at 13:26 Comment(2)
Strongly recommend not doing naive manipulation of HTML strings, it's far too fragile for production use. (Now, a solution actually parsing the HTML -- for instance, DOMParser -- might be a way to go, though it doesn't seem necessary here.)Scrawl
Well, if he's string includes paragraphs, divs and teverything that goes with it, sure, this is a terrible solution. But as long as his data is made out of text and <b> tags, this process should work flawlesslyParturient
P
0

You can make the parent tag a <form> and set the onClick="SomeFunction()".

From the child tag that has the HTML string, set type="button".

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

render() {
return (
     <form onClick={SomeFunction} dangerouslySetInnerHTML = 
      {this.createMarkup(value)}/>
}

createMarkup = value => { 
    return { __html: value };
};
Pittman answered 14/4, 2022 at 6:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.