Search code examples
javascriptreactjsrenderingsettimeout

setTimeout in React implicitly outputs numbers to the DOM


I am trying to implement a delayed typing animation in React, which when started, removes the placeholder text. My attempt to do so, has been by setting state after a timeout, and then render the animation and remove the placeholder when the state is true.

However, using setTimeout outputs some 'random' numbers in its container, and I have not been able to figure out why - I assume that the numbers rendered is the time in milliseconds for the timeout, they only change a few times before stopping.

The output can be seen here:

enter image description here

And an example of the entire component can be seen here:

enter image description here

Essentially I am trying to animate a chat correspondence, and need to render a div looking like an input field. The div has a default placeholder text which needs to be removed after xxxx milliseconds after which the Typist text is rendered displaying the typing animation.

The Chat component depicted below uses a number state as well as a function to increase the number. The number state is used to identify which chat bubbles have already been rendered, since the bubbles have an animation callback which is where the state is being changed - to ensure that the next chat bubble does not start animating in, until the prior one is completely done.

The problem is that I need a timeout to occur when the 'input field' is rendered, since the user has to see the placeholder for a couple of seconds before the typing animation from Typist is triggered.

Chat.jsx

import React, { useEffect, useRef, useState } from 'react';
import ChatBubble from './ChatBubble/ChatBubble';
import classes from './Chat.module.css';
import ScrollAnimation from 'react-animate-on-scroll';
import Typist from 'react-typist';

const Chat = () => {
  const [state, setState] = useState(0);

  const [showInputText, setShowInputText] = useState(false);

  const choices = [{ text: 'Under 2 år siden' }, { text: 'Over 2 år siden' }];

  const choices2 = [{ text: 'Ja' }, { text: 'Nej' }];

  const typistCursor = {
    hideWhenDone: true,
    hideWhenDoneDelay: 200
  };

  let inputText = <Typist cursor={typistCursor}>[email protected]</Typist>;
  if(state >= 6) {
    setTimeout(() => {
      inputText = <div className={classes.InputText}>Indtast din email her...</div>
    }, 1000)
  }

  const inputText = <Typist cursor={typistCursor}>[email protected]</Typist>;

  const renderNextBubble = () => {
    const newState = state + 1;
    setState(newState);
    console.log('test state', state);
  };

  return (
    <div className={classes.chatWrapper}>

      <ChatBubble
        isReply={false}
        animationDelay={0}
        animationCallback={renderNextBubble}
        chatChoices={choices}
      >
        <p>Hvornår købte du din vare?</p>
      </ChatBubble>

      {state >= 1 ? (
        <ChatBubble
          isReply={true}
          animationDelay={0}
          animationCallback={renderNextBubble}
        >
          Under 2 år siden
        </ChatBubble>
      ) : null}

      {state >= 2 ? (
        <ChatBubble
          isReply={false}
          animationDelay={0}
          animationCallback={renderNextBubble}
          chatChoices={choices2}
        >
          <p>Er det under 6 måneder siden at du bestilte/modtog dit køb?</p>
        </ChatBubble>
      ) : null}

      {state >= 3 ? (
        <ScrollAnimation
          animateIn="fadeIn"
          duration={0.5}
          delay={-0.25}
          animateOnce={true}
          afterAnimatedIn={renderNextBubble}
        >
          <div className={classes.DotContainer}>
            <div className={classes.Dot}></div>
          </div>
        </ScrollAnimation>
      ) : null}
      {state >= 4 ? (
        <ScrollAnimation
          animateIn="fadeIn"
          duration={0.5}
          delay={-0.25}
          animateOnce={true}
          afterAnimatedIn={renderNextBubble}
        >
          <div className={classes.DotContainer}>
            <div className={classes.Dot}></div>
          </div>
        </ScrollAnimation>
      ) : null}
      {state >= 5 ? (
        <ScrollAnimation
          animateIn="fadeIn"
          duration={0.5}
          delay={-0.25}
          animateOnce={true}
          afterAnimatedIn={renderNextBubble}
        >
          <div className={classes.DotContainer}>
            <div className={classes.Dot}></div>
          </div>
        </ScrollAnimation>
      ) : null}

      {state >= 6 ? (
        <>
          <ChatBubble
            isReply={false}
            animationDelay={0}
            animationCallback={renderNextBubble}
          >
            <p style={{ fontWeight: 'bold' }}>Du er næsten færdig</p>
            <p>
              Skriv din email nedenunder, så har vi en mulighed for at sende
              klagen til dig
            </p>
            <p style={{ fontWeight: 'bold' }}>
              Dobbelttjek at du har skrevet den rigtige mail!
            </p>
          </ChatBubble>
          <div className={classes.EmailInput}>
            {setTimeout(() => {
              console.log('executing timeout');
              setShowInputText(true);
            }, 1000)}
            {showInputText ? (
              inputText
            ) : (
              <div className={classes.InputText}>Indtast din email her...</div>
            )}
          </div>
        </>
      ) : null}
    </div>
  );
};

export default Chat;

ChatBubble.jsx

import React from 'react';
import classes from './ChatBubble.module.css';
import Typist from 'react-typist';
import ChatChoices from '../ChatChoices/ChatChoices';
import ScrollAnimation from 'react-animate-on-scroll';

const chatBubble = (props) => {
  const { isReply, animationDelay, animationCallback, chatChoices } = props;
  let text = props.children;

  const typistCursor = {
    hideWhenDone: true,
    hideWhenDoneDelay: 200
  };

  if (props.typist) {
    text = (
      <Typist cursor={typistCursor}>
        <Typist.Delay ms={600} />
        {props.children}
      </Typist>
    );
  }

  return (
    <ScrollAnimation
      animateIn="fadeIn"
      duration={1}
      delay={animationDelay}
      animateOnce={true}
      afterAnimatedIn={animationCallback}
    >
      <div
        className={`${classes.chatLine} ${
          isReply ? classes.chatLineWhite : classes.chatLineBlue
        }`}
      >
        <div
          className={`${
            isReply ? classes.chatBubbleBlue : classes.chatBubbleWhite
          } ${classes.chatBubble}`}
        >
          <div>{text}</div>
        </div>
      </div>
      {chatChoices ? <ChatChoices choices={chatChoices} /> : null}
    </ScrollAnimation>
  );
};

export default chatBubble;

ChatChoices.jsx

import React from 'react';
import classes from './ChatChoices.module.css';

const chatChoices = ({ choices }) => {
  return (
    <div className={classes.chatLine}>
      <div className={classes.wrapper}>
        <p>VÆLG EN MULIGHED</p>
        <div className={classes.choicesWrapper}>
          {choices
            ? choices.map((choice) => (
                <div key={choice.text} className={classes.choice}>
                  {choice.text}
                </div>
              ))
            : null}
        </div>
      </div>
    </div>
  );
};

export default chatChoices;

Solution

  • In JSX, {...} outputs the result of the expression within it. (You're relying on this elsewhere, for instance className={classes.InputText}.) You're evaluating setTimeout in {}, which returns a timer handle, which is a number.

    You shouldn't be using setTimeout in your JSX at all. Instead, just run it in the body of your component, if you really want it run every time your component is rendered:

    const Chat = () => {
    
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      const inputText = (<Typist cursor={typistCursor}>[email protected]</Typist>)
    
      // *** Moved
      setTimeout(() => {
        console.log('executing timeout');
        setShowInputText(true);
      }, 1000)
      // ***
    
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
        </div>
      )
    }
    

    Live Example:

    const { useState } = React;
    
    const classes = {
        InputText: {
            color: "green"
        }
    };
    
    const Chat = () => {
    
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      // *** Replaced Typist here just for demo purposes
      const inputText = (<div>[email protected]</div>)
    
      // *** Moved
      setTimeout(() => {
        console.log('executing timeout');
        setShowInputText(true);
      }, 1000)
      // ***
    
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
        </div>
      )
    }
    
    ReactDOM.render(<Chat />, document.getElementById("root"));
    <div id="root"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.11.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.11.0/umd/react-dom.production.min.js"></script>

    But, note that by making setTimeout unconditional, you'll keep doing it again and again even when showInputText is already true. If you only want to do it when it's false, add a branch:

    const Chat = () => {
    
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      const inputText = (<Typist cursor={typistCursor}>[email protected]</Typist>)
    
      // *** Added `if`
      if (!showInputText) {
        // *** Moved
        setTimeout(() => {
          console.log('executing timeout');
          setShowInputText(true);
        }, 1000)
        // ***
      }
    
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
        </div>
      )
    }
    

    Live Example:

    const { useState } = React;
    
    const classes = {
        InputText: {
            color: "green"
        }
    };
    
    const Chat = () => {
    
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      // *** Replaced Typist here just for demo purposes
      const inputText = (<div>[email protected]</div>)
    
      // *** Added `if`
      if (!showInputText) {
        // *** Moved
        setTimeout(() => {
          console.log('executing timeout');
          setShowInputText(true);
        }, 1000)
        // ***
      }
      
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
        </div>
      )
    }
    
    ReactDOM.render(<Chat />, document.getElementById("root"));
    <div id="root"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.11.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.11.0/umd/react-dom.production.min.js"></script>


    In a comment you've said you're worried about the timeout starting before the component is shown, and that the timeout should only start when state >= 6. To do that, use a useEffect callback with state (and showInputText) as dependencies, and set the timer if !showInputText && state >= 6:

    // *** `useEffect` depending on `state` and `showInputText`
    useEffect(() => {
      // You'll see this console log every time the component is rendered
      // with an updated `showInputText` or `state`
      console.log("useEffect callback called");
      // *** Added `if`
      if (!showInputText && state >= 6) {
        console.log("Setting timer");
        // *** Moved
        setTimeout(() => {
          // You'll only see this one when `showInputText` was falsy when
          // the `useEffect` callback was called just after rendering
          console.log('executing timeout');
          setShowInputText(true);
        }, 1000)
        // ***
      }
    }, [showInputText, state]);
    

    Live Example:

    const { useState, useEffect } = React;
    
    const classes = {
        InputText: {
            color: "green"
        }
    };
    
    const Chat = () => {
    
      const [state, setState] = useState(0);
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      // *** Replaced Typist here just for demo purposes
      const inputText = (<div>[email protected]</div>)
    
      // *** `useEffect` depending on `state` and `showInputText`
      useEffect(() => {
        // You'll see this console log every time the component is rendered
        // with an updated `showInputText` or `state`
        console.log("useEffect callback called");
        // *** Added `if`
        if (!showInputText && state >= 6) {
          console.log("Setting timer");
          // *** Moved
          setTimeout(() => {
            // You'll only see this one when `showInputText` was falsy when
            // the `useEffect` callback was called just after rendering
            console.log('executing timeout');
            setShowInputText(true);
          }, 1000)
          // ***
        }
      }, [showInputText, state]);
      
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
          <input type="button" onClick={
              /* Just a really quick and dirty button to let us increment `state` */
              () => setState(s => s + 1)
              } value={`State: ${state} - Increment`} />
        </div>
      )
    }
    
    ReactDOM.render(<Chat />, document.getElementById("root"));
    <div id="root"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.11.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.11.0/umd/react-dom.production.min.js"></script>

    Finally, if your component may be re-rendered for some other reason than the setShowInputText(true) call above, you might want to cancel the timer to avoid outdated calls, via a cleanup function in the useEffect hook:

    // *** `useEffect` depending on `state` and `showInputText`
    useEffect(() => {
      // You'll see this console log every time the component is rendered
      // with an updated `showInputText` or `state`
      console.log("useEffect callback called");
      // *** Added `if`
      if (!showInputText && state >= 6) {
        console.log("Setting timer");
        // *** Moved
        const timer = setTimeout(() => {
          // You'll only see this one when `showInputText` was falsy when
          // the `useEffect` callback was called just after rendering
          console.log('executing timeout');
          setShowInputText(true);
        }, 1000)
        // ***
        // *** This is the cleanup function. It's a no-op if the timer has
        // already fired; if the timer hasn't fired, it prevents it firing
        // twice.
        return () => clearTimeout(timer);
      }
    }, [showInputText, state]);
    

    Live Example:

    const { useState, useEffect } = React;
    
    const classes = {
        InputText: {
            color: "green"
        }
    };
    
    const Chat = () => {
    
      const [state, setState] = useState(0);
      const [showInputText, setShowInputText] = useState(false)
    
      const typistCursor = {
        hideWhenDone: true,
        hideWhenDoneDelay: 200,
      }
    
      // *** Replaced Typist here just for demo purposes
      const inputText = (<div>[email protected]</div>)
    
      // *** `useEffect` depending on `state` and `showInputText`
      useEffect(() => {
        // You'll see this console log every time the component is rendered
        // with an updated `showInputText` or `state`
        console.log("useEffect callback called");
        // *** Added `if`
        if (!showInputText && state >= 6) {
          // *** Moved
          console.log("Setting timer");
          const timer = setTimeout(() => {
            // You'll only see this one when `showInputText` was falsy when
            // the `useEffect` callback was called just after rendering
            console.log('executing timeout');
            setShowInputText(true);
          }, 1000)
          // ***
          // *** This is the cleanup function. It's a no-op if the timer has
          // already fired; if the timer hasn't fired, it prevents it firing
          // twice.
          return () => {
            console.log("Clearing timer");
            clearTimeout(timer);
          };
        }
      }, [showInputText, state]);
      
      return (
        <div className={classes.EmailInput}>
          {showInputText ? (inputText) : (<div className={classes.InputText}>Indtast din email her...</div>)}
          <input type="button" onClick={
              /* Just a really quick and dirty button to let us increment `state` */
              () => setState(s => s + 1)
              } value={`State: ${state} - Increment`} />
        </div>
      )
    }
    
    ReactDOM.render(<Chat />, document.getElementById("root"));
    <div id="root"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.11.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.11.0/umd/react-dom.production.min.js"></script>