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>
)
}
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(...)