I was building a simple Copy button. The kind that copies some text to the clipboard and briefly shows a “Copied!” message for 1 second before reverting to its original label.
I already knew how to implement it the “React way” using useState
to manage the button’s text. But out of curiosity, I decided to try a more direct approach using vanilla JavaScript’s DOM manipulation by changing innerText manually.
That small decision led me down an unexpected path and helped me better understand React’s declarative nature. Let’s explore what happened and why it’s an important lesson for anyone working with React.
The Imperative Attempt
Here's what I tried doing. Instead of relying on React state, I tried change the value of innerText
inside the onClick
handler.
<button
onClick={async (e) => {
navigator.clipboard.writeText(text);
e.currentTarget.innerText = 'Copied!';
await new Promise((resolve) => setTimeout(resolve, 1000));
e.currentTarget.innerText = text;
}}
>
{text}
</button>
This seems like it should work. When the button is clicked:
- The text gets copied to the clipboard.
- I change the
innerText
value of the button to 'Copied!'. - After a second the
innerText
value of the button is changed back to what it was.
But in reality button's innerText
value changed to 'Copied!' but it never changed back to text
value.
Why it doesn't work?
It didn't work simply because React didn't know that I manipulated the DOM.
The issue lies in React's declarative rendering model. The UI is a reflection of component's state and props. When you write:
<button>{text}</button>
You're telling React "The content of this button should always be whatever the value of text
is".
React uses virtual DOM to keep track of what UI should look like and compare it with the real DOM to figure out what needs to be changed.
So when I tried to change the value of innerText
back to text
after 1 second... it failed because React had already re-rendered that component in between due to stricter mode in development. By the time the timeout ran DOM node has already been replaced and that's why it gets stucked to 'Copied!' only.
Vanilla JS DOM manipulation in React is an anti pattern and this is why it isn't advised, they may get silently ignored or break when DOM is rebuilt.
Using useState
Instead of manually updating the DOM, we use useState
to register updates.
import { useState } from 'react';
const CopyButton = ({ text }) => {
const [displayText, setDisplayText] = useState(text);
const handleClick = async () => {
await navigator.clipboard.writeText(text);
setDisplayText('Copied!');
setTimeout(() => setDisplayText(text), 1000);
};
return <button onClick={handleClick}>{displayText}</button>;
}
export default CopyButton
The button's text is controlled by displayText
, a React state variable. When the button is clicked:
- We write to the clipboard.
- We update the state to 'Copied!', triggering a React re-render.
- After 1 second, we reset the state back to the original text, triggering another re-render.
React uses the Virtual DOM to efficiently update the UI with each state change.
Since React is in charge of rendering, the UI always stays in sync with the component’s logic, no surprises, no manual innerText
issues, and no bugs due to stale DOM nodes.
Hi, I'm Samit. A Software Developer and a freelancer who’s always on the lookout for exciting, real world projects to build and contribute to. I love hearing from people, whether it’s to collaborate, share ideas, or work together.
If you're looking to hire a passionate developer or even if you just want to say hi, feel free to check out my portfolio and reach out. I'd love to connect!
Top comments (0)