Impossible to make a Text typer as ReactJS component working with StrictMode or not

I try to create a Text Typer component, where text is added character by character every 100ms.

Everything working good on production, but when I try to run it on dev env, impossible to make it work fine 2 intervals are running at the same time whatever the solution I try to implement (final typed text is ADFHJLNPRTVXZ)

I know that it come from React Strict mode since v18 to catch potential issue, but here it cause issue and I want the same behaviors on every environment.

I tested by addind a clearInterval on top, to stop the first trigger once the second is append. But it’s not workink :/

TextTyper

import React, { Dispatch, useEffect, useRef, useState } from 'react';

export type TextTyperProps = {
  text: string,
  /** Do not forget to wrap it under `useCallback` to avoid refresh the `useEffect` dependencies */
  onTextTyped?: Dispatch<void>,
  intervalTime?: number
};

export const TextTyper = ({ text, onTextTyped, intervalTime = 100 }: TextTyperProps) => {
  const [textTyped, setTextTyped] = useState('');
  const indexRef = useRef(0); // Keep index stored n a ref
  const intervalId = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {

    // Re-init all state when useEffect is called
    setTextTyped("");
    indexRef.current = 0;
    // Clean current interval in case text is udpated before typed completly, or useEffect is runned twice in dev mode
    if (intervalId.current) {
      clearInterval(intervalId.current);
    }

    // Select and type the next chat
    const typeNextChar = () => {
      if (indexRef.current < text.length) {
        setTextTyped((currentText) => {
          const nextChar = text.charAt(indexRef.current);
          console.log(`${indexRef.current}/${text.length} => ${currentText}[${nextChar}] `)
          indexRef.current++;
          return currentText + nextChar;
        });
      } else {
        // Once the text is completly typed, stop interval
        if (intervalId.current) {
          clearInterval(intervalId.current);
        }
        // And propagate
        onTextTyped?.();
      }
    };

    // Start a new interval
    intervalId.current = setInterval(typeNextChar, intervalTime);

    // Clear interval on unmount
    return () => {
      if (intervalId.current) {
        clearInterval(intervalId.current);
      }
    };
  }, [text, onTextTyped, intervalTime]);

  return <div>{textTyped}</div>;
};

Test Component

"use client"

import { useCallback, useState } from "react";
import { TextTyper } from "~/components/ui/TextTyper";



export default function TestTextTyper() {

  const [text, setText] = useState<string>("");
  const [isDone, setDone] = useState<boolean>(false);
  // Additional feature to force reload if ask for the same text
  const [lastUpdate, setLastUpdate] = useState<number>(0);


  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  const revered = alphabet.split('').reverse().join('');
  const handleClick = (newText: string) => {
    setDone(false);
    setText(newText);
    setLastUpdate(Date.now())
  }
  const handleTextTyped = useCallback(() => {
    console.log("Text typing complete");
    setDone(true)
  }, []);

  return (
    <div>
      <button onClick={() => { handleClick(alphabet) }}>{alphabet}</button>
      <button onClick={() => { handleClick(revered) }}>{revered}</button>
      <TextTyper key={lastUpdate} text={text} onTextTyped={handleTextTyped} />
      {isDone && "DONE"}
    </div>
  );
}

When I click on the first button I got those logs (and we see that 2 interval are running on the same time)

TextTyper.tsx:30 0/26 => [A] 
TextTyper.tsx:30 1/26 => [B] 
TextTyper.tsx:30 2/26 => A[C] 
TextTyper.tsx:30 3/26 => A[D] 
TextTyper.tsx:30 4/26 => AD[E] 
TextTyper.tsx:30 5/26 => AD[F] 
TextTyper.tsx:30 6/26 => ADF[G] 
TextTyper.tsx:30 7/26 => ADF[H] 
TextTyper.tsx:30 8/26 => ADFH[I] 
TextTyper.tsx:30 9/26 => ADFH[J] 
TextTyper.tsx:30 10/26 => ADFHJ[K] 
TextTyper.tsx:30 11/26 => ADFHJ[L] 
TextTyper.tsx:30 12/26 => ADFHJL[M] 
TextTyper.tsx:30 13/26 => ADFHJL[N] 
TextTyper.tsx:30 14/26 => ADFHJLN[O] 
TextTyper.tsx:30 15/26 => ADFHJLN[P] 
TextTyper.tsx:30 16/26 => ADFHJLNP[Q] 
TextTyper.tsx:30 17/26 => ADFHJLNP[R] 
TextTyper.tsx:30 18/26 => ADFHJLNPR[S] 
TextTyper.tsx:30 19/26 => ADFHJLNPR[T] 
TextTyper.tsx:30 20/26 => ADFHJLNPRT[U] 
TextTyper.tsx:30 21/26 => ADFHJLNPRT[V] 
TextTyper.tsx:30 22/26 => ADFHJLNPRTV[W] 
TextTyper.tsx:30 23/26 => ADFHJLNPRTV[X] 
TextTyper.tsx:30 24/26 => ADFHJLNPRTVX[Y] 
TextTyper.tsx:30 25/26 => ADFHJLNPRTVX[Z] 
page.tsx:24 Text typing complete