skip to Main Content

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

2

Answers


  1. The issue you’re facing is likely due to the fact that the useEffect hook is running twice in development mode due to React Strict Mode

       export type TextTyperProps = {
      text: string,
      /** Do not forget to wrap it under `useCallback` to avoid refresh the `useEffect` dependencies */
      onTextTyped?: Dispatch<void>,
      intervalTime?: number
    };
    
    Login or Signup to reply.
  2. You don’t need an effect here to be honest.

    export const TextTyper = ({
      text,
      onTextTyped,
      intervalTime = 100,
    }: TextTyperProps) => {
      const [textTyped, setTextTyped] = useState("");
      const intervalId = useRef<number | null>(null);
      const currentIndex = useRef(0);
    
      if (!intervalId.current) {
        intervalId.current = setInterval(() => {
          if (currentIndex.current === text.length && intervalId.current) {
            clearInterval(intervalId.current);
            onTextTyped?.();
          } else {
            setTextTyped(text.substring(0, currentIndex.current + 1));
            currentIndex.current++;
          }
        }, intervalTime);
      }
    
      return <div>{textTyped}</div>;
    };
    

    This is my attempt at it. I have replaced the effect in favour of starting the interval during render. The logic remains the same.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search