공부/React

[React] Refs(참조) 및 Portals(포탈) 활용

hyunh404 2024. 2. 7. 02:29
728x90
Refs, Portals를 활용하지 않고 문제 해결하기(state 활용)

 

페이지에 이름을 입력하면 환영인사가 이름으로 입력되도록 state를 활용하여 수정해보도록 하겠다.

state를 활용해 버튼을 클릭할 때 동작이 이루어지도록 설정하고 'setSumitted(false);'를 추가해주어 입력을 수정할 때마다 같이 수정되는 것을 방지하고 입력창을 수정할 시 다시 'unknown entity' 문구가 페이지에 나타나도록 해주었다.

 

사용자가 이름을 입력할 시 환영인사 메시지가 변경된다.

 

import { useState } from "react";

export default function Player() {
  const [enteredPlayerName, setEnteredPlayerName] = useState("");
  const [submitted, setSubmitted] = useState(false);

  function handleChange(event) {
    setSubmitted(false);
    setEnteredPlayerName(event.target.value);
  }

  function handleClick() {
    setSubmitted(true);
  }

  return (
    <section id="player">
      <h2>Welcome {submitted ? enteredPlayerName : "unknown entity"}</h2>
      <p>
        <input type="text" onChange={handleChange} value={enteredPlayerName} />
        <button onClick={handleClick}>Set Name</button>
      </p>
    </section>
  );
}

 

 

Refs(참조)

 

이제 위에서 state를 활용해 수정했던 내용을 참조를 활용하여 컴포넌트를 간소화해보려 한다.

 

리액트에서 참조는 값이며, import함으로써 사용이 가능하다.

내장 훅 함수이며 컴포넌트 함수나 커스텀 훅 내에서만 호출이 가능하다.

import { useRef } from "react";

 

참조값을 정의한 후에는 함수 내에서 사용함으로써 연결된 요소에 접근할 수 있다.

 

예를들어, playerName을 통해 특정 input 요소에 접근할 수 있다는 것이다.

특히 useRef를 통해 생성된 참조값들을 위해 먼저 current속성에 접근해야한다.

useRef로부터 받는 참조값들은 항상 자바스크립트 객체이며, 항상 current 속성을 가지고 있으며, current속성이 실제 참조값을 가지고 있는 것이다.

 

이와 같이 참조를 이용하면 코드를 간단하게 사용할 수 있다.

또한 playerName.current.value=""; 코드를 사용하여 입력할 때마다 입력창을 초기화 시킬 수 있다. 

import { useState, useRef } from "react";

export default function Player() {
  const playerName = useRef();

  const [enteredPlayerName, setEnteredPlayerName] = useState(null);

  function handleClick() {
    setEnteredPlayerName(playerName.current.value);
    playerName.current.value = "";
  }

  return (
    <section id="player">
      <h2>Welcome {enteredPlayerName ?? "unknown entity"}</h2>
      <p>
        <input ref={playerName} type="text" />
        <button onClick={handleClick}>Set Name</button>
      </p>
    </section>
  );
}

 

Refs(참조) vs State(상태)

 

1. State(상태)

 

상태 업데이트 함수를 통해 변화가 이루어질 때 상태값들은 컴포넌트의 재실행을 야기한다.

따라서 UI에 바로 반영되어야 하는 값들이 있을 때만 사용해야 한다.

UI에 직접적인 영향을 미치지 않는 값들을 갖고 있을 경우 상태를 사용해서는 안된다.

 

2. Refs(참조)

 

반면에 참조는 컴포넌트들이 재실행되게 하지 않는다. 단지 참조값이 바뀌었다는 이유로 재실행되지 않는다.

참조를 사용할 수 있는 경우는 DOM 요소에 직접적인 접근이 필요할 때이다.

 

참조를 활용하여 타이머 컴포넌트 추가하기

 

타이머 게임을 만들 때 목표 시간을 추가하는 컴포넌트를 만들어보려 한다.

TimerChallenge.jsx에 기본 타이머 박스의 내용을 넣고 App.jsx파일에서 목표시간을 설정해준다.

<div id="challenges">
        <TimerChallenge title="Easy" targetTime={1} />
        <TimerChallenge title="Not easy" targetTime={5} />
        <TimerChallenge title="Getting tough" targetTime={10} />
        <TimerChallenge title="Pros only" targetTime={15} />
      </div>

 

위의 코드를 통해 게임 페이지에 타이머 게임을 할 수 있는 박스를 형성했다.

구현한 타이머 박스

 

그러나 아직은 버튼을 눌렀을 때 아무런 동작도 이루어지지 않는다.

이어서 상태와 참조를 활용해 동작을 넣어보겠다.

 

새로운 함수를 정의하여 버튼을 눌렀을 때 타이머를 시작하고 멈추는 동작을 만들려 한다.

여기서 상태도 정의해주어야 한다. 상태 업데이트 함수가 필요하기 때문이다.

    function handleStart () {
        timer = setTimeout(() => {
        setTimerExpired(true);
      }, targetTime * 1000);

      setTimerStarted(true);
    }

    function handleStop() {
      clearTimeout(timer);
    }

    return (
      <>
        <ResultModal ref={dialog} targetTime={targetTime} result="lost" />
        <section className="challenge">
          <h2>{title}</h2>
          {timerExpired && <p>You lost!</p>}
          <p className="challenge-time">
            {targetTime} second{targetTime > 1 ? "s" : ""}
          </p>
          <p>
            <button onClick={timerStarted ? handleStop : handleStart}>
              {timerStarted ? "Stop" : "Start"} Challenge
            </button>
          </p>
          <p className={timerStarted ? "active" : undefined}>
            {timerStarted ? "Time is running..." : "Timer inactive"}
          </p>
        </section>
      </>
    );

 

이제 버튼을 누른 후 목표 시간을 맞추지 못한 경우에는 you lose라는 문구가 뜬다.

컴포넌트가 재실행되어 상태를 업데이트하므로 변수를 함수 안에 두면 변수를 사용할 수 없기 때문에 컴포넌트 함수 밖에서 정의할 때만 변수를 사용할 수 있다. 변수를 함수 밖에 정의하면 재생성되지 않는다.

 

타이머 변수 설정 오류 해결

 

여러개의 타이머 중 5초 타이머를 먼저 시작한 후 5초가 지나기 전 1초타이머의 시작 버튼을 누르면 바로 5초 타이머에 졌다는 문구가 뜨는 오류가 발생했다.

이는 변수가 컴포넌트 함수 밖에 설정되어 타이머의 포인터가 덮어씌워지는 오류가 발생하기 때문이다.

변수가 동일한 파일에 정의되어 컴포넌트의 모든 인스턴스들과 공유되기 때문에 변수를 사용하면 해결할 수 없는 문제인 것이다.

 

따라서 참조를 활용하여 오류를 해결해야한다.

참조는 HTML요소와 연결하는 것뿐만 아니라 어떤 종류의 값이든 제어하기 위해 사용하기도 한다.

 

새로운 timer참조를 생성하여 timer.current를 설정하겠다.

참조로서 timer 값을 설정해주면 각 인스턴스만의 참조값을 가질 것이고 다른 인스턴스의 참조값들과 독립적으로 동작하게 된다.

또한 참조는 초기화되거나 지워지지 않는다.

const timer = useRef();
  function handleStart () {
      timer.current = setTimeout(() => {
      setTimerExpired(true);
    }, targetTime * 1000);

    setTimerStarted(true);
  }

  function handleStop() {
    clearTimeout(timer.current);
  }

  return (
    <>
      <ResultModal ref={dialog} targetTime={targetTime} result="lost" />
      <section className="challenge">
        <h2>{title}</h2>
        {timerExpired && <p>You lost!</p>}
        <p className="challenge-time">
          {targetTime} second{targetTime > 1 ? "s" : ""}
        </p>
        <p>
          <button onClick={timerStarted ? handleStop : handleStart}>
            {timerStarted ? "Stop" : "Start"} Challenge
          </button>
        </p>
        <p className={timerStarted ? "active" : undefined}>
          {timerStarted ? "Time is running..." : "Timer inactive"}
        </p>
      </section>
    </>
  );

 

위와 같이 참조를 활용하면서 타이머 오류를 해결할 수 있었다.

 

 

 

728x90

 

 

팝업창으로 게임 결과 알리기

 

타이머의 목표시간에 가깝게 stop버튼을 누를수록 높은 점수를 받는 게임 결과를 팝업창을 통해 알리는 기능을 구현하고자 한다.

새로운 모달 컴포넌트를 추가하여 화면에 뜨는 대화창을 만들어주도록 하겠다.

또한 대화창이 뜰때 뒤의 배경을 어둡게 만들어 대화창이 잘보이도록 만들어주겠다.

 

이때 참조를 다른 컴포넌트로 전달하고 사용하기 위해서는 리액트가 제공하는 특별한 함수를 사용해야한다.

forwardRef 함수를 import하여 사용하면 다른 컴포넌트간 참조값을 전달할 수 있다.

forwardRef 함수로 컴포넌트 함수를 감쌀때는 두번째 인자를 받는다. 즉, 2번째 매개변수인 ref를 받게된다.

 

따라서 이제 dialog.current.showModal을 호출하여 뒷 배경을 어둡게 만든 후 대화창을 띄울 수 있다.

import { forwardRef, useImperativeHandle, useRef } from "react";
const ResultModal = forwardRef(function ResultModal(
  { result, targetTime },
  ref,
) {
   return (
      <dialog ref={dialog} className="result-modal">
        {userLost && <h2>You lost</h2>}
        {!userLost && <h2>Your Score: {score}</h2>}
        {/* <h2>You {result}</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>
    );
});

export default ResultModal;
dialog.current.showModal()

 

dialog 참조 변경 오류 해결

 

dialog 참조를 사용하면 값이 변경될 시 showModal을 호출하는 것은 더이상 작동하지 않기 때문에 오류가 발생할 수 있다.

따라서 컴포넌트 외부에서 ref의 도움으로 호출될 수 있도록 하는 것이 좋다.

이는 useImperativeHandle 훅을 활용하여 해결할 수 있다.

이제 dialog 요소를 분리해야한다. open메소드를 정의하여 showModal 대신 open을 호출할 수 있게 된다.

dialog.current.open()

const ResultModal = forwardRef(function ResultModal(
  { result, targetTime },
  ref,
) {
  useImperativeHandle(ref, () => {
    return {
      open() {
        dialog.current.showModal();
      },
    };
  });

배경은 어둡게 대화창 띄우기

 

게임 결과 점수와 남은 시간 표시하기

 

점수와 남은 시간을 표시하기 위해서 setInterval함수를 사용하려한다.

이는 한번뿐만 아니라 시간이 만료될때마다 함수를 실행할 것이다.

따라서 targetTime을 아주 짧은 기간으로 설정하여 시간이 얼마나 만료되었는지 추적하도록 해주겠다.

 

남은시간을 다루기 위해서는 상태값을 이용해야한다.

타이머가 시작된 것을 알기 위해 남은시간의 범위를 설정하고 if문을 통해 남은시간이 0보다 작거나 같은지 확인하여 계속해서 실행이 되지 않도록 수동으로 멈추고 setTimeRemaining도 초기값으로 재설정해준다.(무한루프 방지)

if (timeRemaining <= 0) {
    clearInterval(timer.current);
    setTimeRemaining(targetTime * 1000); //초기값 다시 설정
    dialog.current.open(); //시간 내에 멈추지 못함 짐
  } //무한루프 생성 방지

 

패배메시지와 점수 계산, 남은 시간 계산을 위해 userLost와 formattedRemaining을 설정하여 계산과정을 정의해준다.

또한 새로운 속성을 받고 함수를 정의해 계산결과와 함께 패배메시지도 같이 보이도록 해주겠다.

const userLost = remainingTime <= 0;
  const formattedRemainingTime = (remainingTime / 1000).toFixed(2);
function handleReset() {
    setTimeRemaining(targetTime * 1000);
  }
return (
    <>
      <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 : handleStart}>
            {timerIsActive ? "Stop" : "Start"} Challenge
          </button>
        </p>
        <p className={timerIsActive ? "active" : undefined}>
          {timerIsActive ? "Time is running..." : "Timer inactive"}
        </p>
      </section>
    </>
  );
const ResultModal = forwardRef(function ResultModal(
  { targetTime, remainingTime, onReset },  ref,
)
<form method="dialog" onSubmit={onReset}>

 

따라서 점수와 남은 시간을 계산하여 대화창에 출력할 수 있게 되었다.

점수는 남은 시간이 0에 가까울 수 록 높아진다.

게임 결과 점수와 남은 시간 표시

 

Portals(포탈)

 

포탈을 이해하기 위해 대화창인 모달을 분석하면 대화상자 요소가 DOM의 모든 위치에 삽입되어 있음을 볼 수 있었다.

특히 끝에 있는 다른 HTML요소들에 중첩되어 있다.

이유는 모달 컴포넌트다 jsx코드의 일부로 출력되어 타이머 컴포넌트로 돌아가기 때문이다.

따라서 모달이 출력된 곳은 섹션의 출력위치와 동일하다.

이는 다른 요소들에 의해 묻혀 숨겨질 수도 있는 위험성이 존재한다.

 

따라서 컨트롤하거나 타이머 컴포넌트에 있는 모달을 출력하기 위해 포탈을 사용하게 된다.

포탈을 사용하기 위해 import해주어야 하며 포탈의 의의는 컴포넌트에 렌더링 될 HTML코드를 DOM 내에 다른 곳으로 옮기는 것이다.

import { createPortal } from "react-dom";

 

그러기 위해 createPortal로 jsx코드를 감싸 보내준다. 여기서 jsx코드는 첫번째 인수이며 createPortal은 두번째 인수를 받게 된다. 두번째 인수는 HTML요소로 결국엔 코드를 렌더링 해주어야한다.

따라서 기본 브라우저 API로 선택해야하고 document.getElementByld를 사용하면 된다.

 

이렇게 하면 이전과 마찬가지로 팝업창이 뜨지만 개발자도구를 활용해 모달을 분석하면 모달이 분리되어 있는 것을 확인할 수 있다.

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"),
  );

 

 

 

시연 영상

 

 

이렇게 해서 타이머 게임 페이지를 구현해보았다.

전체 코드는 깃허브 주소를 남기도록 하겠다.

https://github.com/BB545/-react-timer-game

728x90