본문 바로가기
React 관련/기타

모달 컴포넌트를 통한 React 동작 원리 분석(with 자바스크립트 비동기와 리액트 동작 원리)

by ash9river 2024. 7. 15.

서론

학교에서 후배랑 같이 코딩하는데, 후배가 인강을 보다가 코드가 제대로 작동이 안된다고 해서 그 원리를 세세하게 파악해보겠다.

 

먼저 단순히 버튼을 누르면 타이머가 돌아가고, 그 타이머의 동작이 끝나면 모달이 열린다.

코드는 다음과 같다.(후배의 정확한 코드는 모르고 최대한 비슷한 환경을 구성하였다.) 

모달 컴포넌트

forwardRef랑 useImperativeHandle을 사용해서 재사용성있게 모달 컴포넌트(ResultModal)를 구현한 코드이다.
useImperativeHandle을 사용했기 때문에, 모달 컴포넌트의 바깥에서 open() 함수를 통해 모달을 열 수 있다.

 

import { forwardRef, useImperativeHandle, useRef } from "react";
import { createPortal } from "react-dom";

const ResultModal = forwardRef(function ResultModal(
  { targetTime, remainingTime, onReset },
  ref
) {
  const dialog = useRef();

  const userLost = remainingTime <= 0;
  const formattedRemainingTime = (remainingTime / 1000).toFixed(2);
  const score = Math.round((1 - remainingTime / (targetTime * 1000)) * 100);

  useImperativeHandle(ref, () => {
    return {
      open() {
        dialog.current.showModal();
      },
    };
  });

  return createPortal(
    <dialog ref={dialog} className="result-modal">
      {userLost && <h2>You lost</h2>}
      {!userLost && <h2>Your Score: {score}</h2>}
      <p>
        The target time was <strong>{targetTime} seconds.</strong>
      </p>
      <p>
        You stopped the timer with{" "}
        <strong>{formattedRemainingTime} seconds left.</strong>
      </p>
      <form method="dialog" onSubmit={onReset}>
        <button>Close</button>
      </form>
    </dialog>,
    document.getElementById('modal')
  );
});

export default ResultModal;

 

부모 컴포넌트

 

버튼이 클릭되면 타이머가 돌아가고, 타이머가 다 돌아가야 모달이 열리는 방식이다.
다른건 제외하고 expired랑 if 함수 그리고 ResultModal 컴포넌트의 조건부 렌더링을 볼 것이다.

 

import { useRef, useState } from "react";
import ResultModal from "./ResultModal";

export default function TimerChallenge({ title, targetTime }) {
  const timer = useRef();
  const dialog = useRef();

  const [timeRemaining, setTimeRemaining] = useState(targetTime * 1000);
  const [expired, setExpired] = useState(false);
  const timerIsActive = timeRemaining > 0 && timeRemaining < targetTime * 1000;

  if (timeRemaining <= 0) {
    clearInterval(timer.current);
    setExpired(true);
    dialog.current.open();
    handleReset();
  }

  function handleReset() {
    setTimeRemaining(targetTime * 1000);
  }

  function hadleStart() {
    timer.current = setInterval(() => {
      setTimeRemaining((prevTime) => prevTime - 10);
    }, 10);
  }

  function handleStop() {
    dialog.current.open();
    clearInterval(timer.current);
  }

  return (
    <>
      {expired && (
        <ResultModal
          ref={dialog}
          targetTime={targetTime}
          remainingTime={timeRemaining}
          onReset={handleReset}
        />
      )}
      <section className="challenge">
        <h2>{title}</h2>
        <p className="challenge-time">
          {targetTime} second{targetTime > 1 ? "s" : ""}
        </p>
        <p>
          <button onClick={timerIsActive ? handleStop : hadleStart}>
            {timerIsActive ? "Stop" : "Start"} Challenge
          </button>
        </p>
        <p className={timerIsActive ? "active" : undefined}>
          {timerIsActive ? " Time is running" : "Timer Inactive"}
        </p>
      </section>
    </>
  );
}

작동

페이지 사진

다음의 스타트 챌린지 버튼을 클릭하면 타이머가 돌아가고, 모달이 열려야 하는데 다음과 같은 오류가 뜬다.

Cannot read properties of undefined (reading 'open')

 

setExpired(true)로 인해 expired가 true가 되고 모달이 성공적으로 열릴 것 같지만 그건 잘못된 생각이다.

 

원인 분석

자바스크립트의 비동기적 실행과 리액트의 동작이 원인이다.

 

 

자바스크립트의 비동기적 실행으로 인해 setExpired(true)가 실행되고, 그 실행이 완료되기 전에 그 다음줄인 dialog.current.open()을 실행시킨다.

if (timeRemaining <= 0) {
  clearInterval(timer.current);
  setExpired(true);
  dialog.current.open();
  handleReset();
}

 

이때, ResultModal의 조건부렌더링으로 인해 dialog는 모달 컴포넌트와 연결되지 않아서 open() 함수를 가지고 있지 않다.

그래서 open() 함수를 읽을 때, undefined 속성을 읽었다고 오류가 표시되는 것이다.

{expired && (
  <ResultModal
    ref={dialog}
    targetTime={targetTime}
    remainingTime={timeRemaining}
    onReset={handleReset}
  />
)}

 

 

만약 dialog.current.open()를 if(expired) dialog.current.open(); 로 if문이 추가된 형태로 고친다면 무슨 일이 벌어질까?

  if (timeRemaining <= 0) {
    clearInterval(timer.current);
    setExpired(true);
    if (expired) dialog.current.open();
    handleReset();
  }

 

신기하게도 정상적으로 동작되는데, 이는 리액트의 렌더링 사이클과 관련있다.

 

setExpired(true)가 실행되고, if(expired) dialog.current.open()은 조건문에 의해 실행되지 않는다.

그 후, DOM 트리에는 ResultModal 컴포넌트가 추가된다.

 

ResultModal 컴포넌트가 추가된 모습

 


if( timeRemaining <= 0 ) 문의 모든 코드가 실행된 후에, expired가 true가 되었기에 state 변경으로 인한 Re-render가 발생한다. 그리고 그 Re-render 과정에서 자바스크립트의 document.appendChild()를 통해 ResultModal 컴포넌트가 DOM트리에 추가된다.(이미 if(expired) dialog.current.open()는 실행이 되었기때문에 모달이 열리지는 않는다.)

 

리액트의 동작 원리는 다음 글의 렌더링 사이클 부분에서 확인하면 된다.

 

React 렌더링 사이클과 useEffect, 그리고 useRef

ref와 useEffectuseEffect는 굉장히 섬세하다. 웬만해서는 사용해서는 안된다. 그런데, 이전에 구글 맵을 렌더링할 때, useEffect와 ref를 같이 사용함으로써, 예기치 못한 부작용들이 발생할 수 있었다. 

ash9river.tistory.com

 

 

만약 정상적으로 동작시키고 싶으면 JSX 조건부 렌더링을 삭제함과 동시에 expired, setExpired를 전부 삭제하면 된다.

 

후기

리액트의 동작 원리는 물론이고 자바스크립트를 잘 알고 있어야 실수를 덜 하지 않을까...? 물론 이 글을 포스팅하면서도 후배의 코드와는 달라서 조금 헤맸지만 큰 맥락은 다르지 않다.

 

그녀석한테 하라 했는데 안해서 적음

 

'React 관련 > 기타' 카테고리의 다른 글

React Do not use or you will be fired  (0) 2024.07.23