ReactJS How do I use state hooks for a list of text changing buttons with a timeout?

Viewed 1502

This may be confusing but I appreciate the help. I essentially have a list of data with a "Copy" button next to each one. On click I want it to change to "Copied!" for 500 milliseconds and then go back to "Copy".

My setup is as follows: I have an array with the list of data that I've passed as a prop into a component (lets say array x).

Using this array I create a useEffect hook (that updates when the data array x is updated). This hook updates a new state with the new array (lets say array y) of the length of the original array with the text "Copy".

Seems to work great as far as I can tell, now heres where my problem is: I then create a handler for the button where I create a new array, using the dot notation spread and update the index of the button from "Copy" to "Copied!". Then a time out puts it back to "Copy" (code below).

Works great, but as you suspect I have a bug. If I hit copy on another button too quickly, I end up with the original "copied" button stuck there. As I'm guessing I'm updating the state faster then the other state drops back.

function copyButtonClipBrd(index) {
  console.log("Test Copy Button");
  console.log(cpyBTN);
  let newArr = [...cpyBTN];
  newArr[index] = "Copied!";
  setCpyBTN(newArr);
  
  setTimeout(function () {
    let newArr = [...cpyBTN];
    newArr[index] = "Copy";
    setCpyBTN(newArr);
  }, 600);
}

Would love any advice, or even better ways to do this as I'm largely doing this for learning. Thanks all!

4 Answers

This is the issue with using the state directly for manipulations for it, instead ReactJS provides a callback version of setState which allows you to access the currentState at the time of execution as a parameter. You can use this as:

setCpyBtn((currentState) => {
  return currentState.map((item, i) => {
    if (i === index) {
      return 'Copy';
    }
    return 'Copied';
  });
})

and of course vice versa.

Docs

import React, { useEffect, useState, useCallback, useRef } from 'react'
import { range } from 'lodash-es'

const App = () => {
  const [items, setItems] = useState(
    range(100).map((val) => ({ id: val, val, text: 'Copy' })),
  )

  const [copiedItem, setCopiedItem] = useState<number | undefined>(undefined)

  const timeout = useRef<number | undefined>(undefined)

  useEffect(() => {
    if (copiedItem) {
      setItems((itemsState) =>
        itemsState.map((item) =>
          item.id === copiedItem
            ? { ...item, text: 'Copied' }
            : { ...item, text: 'Copy' },
        ),
      )
    } else {
      setItems((itemsState) =>
        itemsState.map((item) => ({ ...item, text: 'Copy' })),
      )
    }
  }, [copiedItem])

  const handleClick = useCallback(
    (itemId) => () => {
      if (timeout.current) {
        window.clearTimeout(timeout.current)
      }
      setCopiedItem(itemId)
      timeout.current = window.setTimeout(() => {
        setCopiedItem(undefined)
      }, 1000)
    },
    [],
  )

  return (
    <div>
      {items.map((item) => (
        <div key={item.id} className="mb-4">
          <span className="mr-2">{item.val}</span>
          <button className="p-2 bg-gray-100" onClick={handleClick(item.id)}>
            {item.text}
          </button>
        </div>
      ))}
    </div>
  )
}

export default App

  • You can use another state copiedItem which either stores the id or the index of the item which is set to be copied. In the useEffect we can look for the change in copiedItem to set the text to be Copied.

  • In addition to that we also need a reference to the previous timeout, so that we could clear it before triggering the next items timeout.

So I tried this using @Agney suggestion to use callback in setState. here is codesanbox link https://codesandbox.io/s/quirky-dubinsky-lm03x?file=/src/App.js

so basically we are checking index in setstate callback to initially set it to copied and in timeout callback we are again checking same index to set back to copy.

You don't need to update the state, when any button is clicked, just to show a different text on the button.

Add a click event listener on the parent element of the list that contains all the data and check if the element that triggered the click event is a copy button or not. If it is, change its text and also set a timer that will revert the text back to "Copy"

Demo:

const url = 'https://jsonplaceholder.typicode.com/todos';

function App() {
  const [todos, setTodos] = React.useState([]);
  
  React.useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(todos => setTodos(todos))
      .catch(err => console.log(err.message));
  }, []);

  return (
    <div className="App">
      <TodoList todos={todos}/>
    </div>
  );
}

function TodoList(props) {

  function changeBtnText(event) {
    const target = event.target;

    if (target.matches("button")) {
      target.textContent = "Copied";
      setTimeout(() => (target.textContent = "Copy"), 1000);
    }
  }

  return (
    <div className="todoList" onClick={changeBtnText}>
      {props.todos.map(t => (
        <Todo key={t.id} {...t} />
      ))}
    </div>
  );
}

function Todo({ title, completed }) {
  return (
    <div className="todo">
      <span>TItle: {title}</span>
      <button>Copy</button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
.App {
  font-family: sans-serif;
  text-align: center;
}

.todo {
  box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
  margin: 10px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 10px;
}

.todo button {
  padding: 5px 10px;
  margin: 10px 0 0 0;
  cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>

<div id="root"></div>

You can also view this demo on Codesandbox

Edit hardcore-leaf-oxpf6

Related