Is it possible to make a recursive function in React that can be passed in state

Viewed 285

I'm trying to render items from an array stored in state one at a time to the screen. After an item is displayed on the screen it is removed from the array, then the next item is displayed and so on until there are no items left in the array. The catch that has me sprung is that items can be added to the array via websockets while the function is already running. So essentially I want to create a live item queue. When an item or items are added to the queue, I want to the function to trigger and display the items on screen one by one until the queue is empty.

I thought using a recursive function that could initially be triggered by a useEffect might work. But what's weird is that inside my animateDisplay function the value for items never changes, but outside the function it changes like expected. It's like the value for items is cached within the recursive function. So my function repeats forever:

console.log(`outside ${items}`) // If items started as [1,2,3] it would log [1,2,3] then [2,3] then [3] on each render

const animateDisplay = async () => {
    running.current = true
    setDisplay(items[0])
    await timeout(4000)
    setDisplay({})
    setItems(prevState => prevState.slice(1))

    console.log(`inside ${items}`) // If items started as [1,2,3] it would log [1,2,3] on every render

    if (items.length > 0) {
        await timeout(4000)
        await animateDisplay()
    }
    running.current = false
}

useEffect(() => {
    if (running.current === false && items.length > 0) {
        animateDisplay()
    }
}, [items])

Is there anyway I can achieve what I am after?

4 Answers

The main issue is the stale enclosure. Since you say you are basically working from a queue then you can use a React ref and an interval timer to access the front of the queue and also dequeue elements on the interval.

Example:

const [queue, setQueue] = useState([]);

// Ref to store current queue state reference
const queueRef = useRef();

useEffect(() => {
  queueRef.current = queue;
}, [queue]);

// Effect to run the interval and manage the queue
useEffect(() => {
  const timer = setInterval(() => {
    // Access queue front
    const [front] = queueRef.current;

    // Dequeue
    setQueue((queue) => queue.slice(1));
  }, 4000);
  return () => clearInterval(timer);
}, []);

const enqueue = () => {
  setQueue((queue) => queue.concat(/* new value */));
};

Edit is-it-possible-to-make-a-recursive-function-in-react-that-can-be-passed-in-state

Update

Update to only run the queue when it's popoulated.

const [queue, setQueue] = useState([]);

const timerRef = useRef(null);
const queueRef = useRef();

const startQueue = () => {
  // process function
  const tick = () => {
    const [front] = queueRef.current;
    if (!queueRef.current.length) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
    front && console.log(front);
    setQueue((queue) => queue.slice(1));
  };

  // start process
  timerRef.current = setInterval(tick, 1000);

  // immediately invoke
  tick();
};

useEffect(() => {
  // cache reference to queue
  queueRef.current = queue;

  // if queue populated and not running, start queue
  if (queue.length && !timerRef.current) {
    startQueue();
  }
}, [queue]);

useEffect(() => {
  // clear any running timers when unmounting
  return () => clearInterval(timerRef.current);
}, []);

const enqueue = () => {
  setQueue((queue) => queue.concat(count++));
};

Edit is-it-possible-to-make-a-recursive-function-in-react-that-can-be-passed-in-state (forked)

You could accomplish this with a setTimeout. If there's an item in the queue, show it and restart the timeout. The example below isn't React specific, but it demonstrates the basic idea, including items being added to the queue while the thing is running.

// initial data
const items = [1,2,3,4,5,6];

// get references to dom elements
const appended = document.querySelector('#appended');
const arrayElem = document.querySelector('#array');
const button = document.querySelector('button');

// keep track of the timeout id just so we know
// whether it's currently running.
let timeoutId;

// add an item to the queue when the button is clicked.
button.addEventListener('click', () => {
  items.push((items[items.length - 1] || 0) + 1);
  arrayElem.innerText = JSON.stringify(items);
  
  // if the timeout isn't already running, start it.
  if (!timeoutId) {
    update();
  }
});


function update (delay = 1000) {
  // no items left? clear timeoutId and bail.
  if(!items.length) {
    timeoutId = null;
    return;
  }
  
  // get the first item in the queue
  const x = items.shift();
  
  // append it to the display
  appended.innerHTML += `<div>${x}</div>`;
  
  // update the queue display
  arrayElem.innerText = JSON.stringify(items);
  
  // restart the timeout
  timeoutId = setTimeout(update, delay);
}

update();
<button>Add Item</button>
<pre id="array"></pre>
<pre id="appended"></pre>

Its because its the function that is recurring, is the one that is create on the first run. You could use another useEffect to place changes to items in a useRef, and get the list in your function via the ref.

This way you have a object with the current state even if the function was created earlier.

But it would properly be better to do it all another way around.

Make a new component that is your animated display of one item, and that have a callback when the animation is complete.

Then in your list component have a useState to hold the current displayed item. And when the callback fires on the AnimatedDisplay you iterate to the next it in the list, and the AnimatedDisplay restarts the animation with the new item.

Use setIntervals. Define a counter named i let i=0 the first function is called every 4 second and setDisplay is utilized whenever the function is called. and finally i is incremented to render the next item of the list.

setIntervals(() =>{
setDisplay(items[i]);
i++
 , 4000);
Related