javascriptreactjs

Why the useEffect code in the component run again while an irrelevant state in its parent component updated?


I am building a chat-like app using next.js. In the app, the text will be read by the app.

I am using two states to store the text for reader, the speechText for the current reading text and the nextSpeechTexts for the texts in the queue.

And I separated the conversation into blocks, and one block may include one or more items (every item would be a balloon in the real app). I want the items in the last block to show one by one with about 1.5s interval.

Now, the problem is the reading and the block showing are not synced since it is not necessary, but while the Tts component finished reading and start reading the next text, the last block would be cleared and show the items one by one in it again.

I suppose it would not be re-rendered since no props passed to it changed, even the references are the same. I just updated the speechText and nextSpeechTexts, both are not used by the ChatItem component.

But why is it now re-rendered (or re-mounted)? And how to make it stable?

The following is a minimal reproduction of the page (I replaced real tts service with a timeout text showing), and also put it onto codepen so you can play with it:

import { memo, useCallback, useEffect, useState } from "react"


const Tts = memo(({text, index, disabled, onStartSpeaking, onEndSpeaking}: {text: string, index: number, disabled?: boolean, onStartSpeaking?: () => void, onEndSpeaking?: () => void}) => {
  useEffect(() => {
    setTimeout(() => {
      onEndSpeaking?.()
    }, 5000)
  })
  const styles = [
    'bg-red-200',
    'bg-yellow-200',
    'bg-green-200',
    'bg-blue-200',
  ]
  return (
    <div className={styles[index % styles.length]}>Reading: {index}-{text}</div>
  )
})

const ChatItem = memo(({items, slowPop}: {items: string[], slowPop?: boolean}) => {
  const [visibleItems, setVisibleItems] = useState<string[]>([])
  useEffect(() => {
    if (slowPop) {
      const timeouts = items.map((item, index) => {
        const delay = 1500 * index
        return setTimeout(() => {
          setVisibleItems(items.slice(0, index + 1))
        }, delay)
      })
      return () => {
        timeouts.forEach(timeout => clearTimeout(timeout))
      }
    } else {
      setVisibleItems(items)
    }
  }, [items, slowPop])

  return (
    <div className="chat-item">
      <ul>
        {visibleItems.map((item, index) => (
          <li key={index} className="chat-item-text animate-fadeIn">
            {item}
          </li>
        ))}
      </ul>
    </div>
  )
})

function Content() {
  type SpeechTextMeta = {
    index: number
    text: string
  }
  const [speechText, setSpeechText] = useState<SpeechTextMeta>({
    index: 0,
    text: 'This is a test, and I will speak for a while.',
  })

  const [nextSpeechTexts, setNextSpeechTexts] = useState<SpeechTextMeta[]>([
    { index: 1, text: 'This is the followed text.' },
  ])

  const chatBlocks = [
    [
      'previous chat history...',
      'previous chat history...',
    ],
    [
      'I am doing well too.',
      'What are you up to today?',
      'Just working on some projects.',
    ],
  ]

  const handleEndSpeaking = useCallback(() => {
    if (nextSpeechTexts.length > 0) {
      const nextText = nextSpeechTexts[0]
      setNextSpeechTexts(nextSpeechTexts.slice(1))
      setSpeechText(nextText)
    }
  }, [nextSpeechTexts])

  return (
    <div>
      <Tts text={speechText.text} index={speechText.index} onEndSpeaking={handleEndSpeaking} disabled={false} />
      <ul>
        {chatBlocks.map((block, index, a) => (
          <li key={index} className="chat-item-title py-2">
            <ChatItem items={block} slowPop={index === a.length - 1} />
          </li>
        ))}
      </ul>
    </div>
  )
}

export default function Page() {
  const [show, setShow] = useState(false)
  return (
    <div>
      <button onClick={() => setShow(!show)} className="border border-gray-400 shadow-lg cursor-pointer active:bg-gray-500">Toggle</button>
      {show && <Content />}
    </div>
  )
}

Solution

  • The effect in ChatItem re-runs on items change, and items change on each Content re-render because chatBlocks is a new array every time.

    It should be:

    const chatBlocks = useRef(...);
    

    and

    chatBlocks.current.map(...)