231229 TIL

thisisyjin·2023년 12월 29일
0

TIL 📝

목록 보기
107/113

Refs & Portals

Practice (useState ver.)

import { useState } from "react";

export default function Player() {

  const [enteredPlayerName, setEnteredPlayerName] = useState('');
  const [submitted, setSubmitted] = useState(false);
  const handleChange = (e) => {
    setEnteredPlayerName(e.target.value)
  }

  const handleClick = () => {
    setSubmitted(true);
  }
  
  return (
    <section id="player">
      <h2>Welcome {submitted ? enteredPlayerName : 'unknown entity'}</h2>
      <p>
        <input type="text" value={enteredPlayerName} onChange={handleChange}/>
        <button onClick={handleClick}>Set Name</button>
      </p>
    </section>
  );
}
  • 위 코드에서의 문제점은, 클릭을 하고 나면 onChange 할 때마다 enteredPlayerName이 변하게 되고,
    화면에서 리렌더링 된다는 점이다.

Practice (Ref ver.)

  • Refs는 참조값을 의미.
  • 그러나, 리액트가 특별한 방법으로 다루는 값.
  • useRef Hook을 이용하여 생성 가능.

Ref는 여러 기능을 하지만, 가장 많이 사용되는 용법은 JSX와 연결(DOM)하는 기능이다.

const playerName = useRef();

...

<input ref={playerName} />
  • 위 practice 코드를 ref를 이용한 코드로 변경해보자.
import { useState, useRef } from "react";

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

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

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

[주의] useRef 사용 시 주의할 점

  • 만약, 위 코드에서 input의 값을 비우고 싶다면 어떻게 해야할까?
    -playerName.current.value = '';와 같이 DOM을 직접 조작하게 된다면,
    React의 '선언형' 코드 규칙에 위반된다.
  • 즉, 경우에 따라 state 대신 ref로 값을 읽어들이는 것은 괜찮지만, DOM을 직접 조작해서는 안된다. (state를 사용해야 함)

?? 연산자

  • enteredPlayerName ? enteredPlayerName : 'unknown entity' 라는 코드를 줄여서 표현 가능.
  • 널 병합 연산자 (??)
  • 이는 왼쪽 피연산자가 null 또는 undefined 뿐만 아니라 falsy 값에 해당할 경우 오른쪽 피연산자를 반환하는 논리 연산자 OR (||)이 대조된다.
  • 참고 문서 - Nullish Coalescing Operator

Ref

  • Ref의 값과 state의 가장 큰 차이점은 동작 방식이다.
  • state가 변화하면 화면이 리렌더링되지만,
  • ref 값이 변화하면 화면이 리렌더링되지 않는다.
  • 즉, ref가 변화하면 컴포넌트 함수가 재실행되지 않는다!
  • state가 변화하면 (setState 함수에 의해서만 변경됨) -> 컴포넌트 함수가 재실행됨 -> render, 즉 JSX return문이 다시 실행됨
  • ref가 변화하면 -> 컴포넌트 함수가 재실행되지 않음.

State vs. Ref

  • UI에 바로 적용되어야 하는 값들을 state로 사용해야 함.
  • 시스템 내부에서만 보이는 값이거나, UI에 바로 적용되어선 안되는 값은 ref로 사용해야 함.
  • 단, DOM에 직접적인 접근이 필요한 경우에는 ref를 사용하면 안된다.

과제 - Time Challenge

  1. src/components/TimeChallenge.jsx 생성
export default function TimerChallenge ({ title, targetTime }) {

    return (
        <section className="challenge">
            <h2>{title}</h2>
            <p className="challenge-time">
              {targetTime} Second{targetTime > 1 ? 's' : ''}
            </p>
            <p>
                <button>
                    Start Challenge
                </button>
            </p>
            <p className="active">
                Time is running... / Timer inactive
            </p>
        </section>
    )
}
  1. App.jsx 수정
import Player from './components/Player.jsx';
import TimerChallenge from './components/TimerChallenge.jsx';

function App() {
  return (
    <>
      <Player />
      <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>
    </>
  );
}

export default App;

Ref 사용 - 변수

  • setTimeout 함수를 변수(let)로 지정해서 쓰는 경우
  • 타이머가 정상적으로 작동하지 않음.
  • state가 변하면 -> 컴포넌트 함수가 재실행 되고 -> timer 변수도 재할당되기 때문.
  • 이럴 때, state 대신 ref를 사용하여 함수의 재실행을 막을 수 있음.
// 일반 변수를 사용한 경우 
import { useState } from "react";

export default function TimerChallenge ({ title, targetTime }) {
    const [timerStarted, setTimerStarted] = useState(false);
    const [timerExpired, setTimerExpired] = useState(false);

    let timer;

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

    const handleStop = () => {
        setTimerStarted(false); 
        clearTimeout(timer);
    }

    return (
        <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 Challenge' : 'Start Challenge'}
                </button>
            </p>
            <p className={timerStarted ? 'active' : undefined}>
                {timerStarted ? 'Time is running...' : 'Timer inactive'}
            </p>
        </section>
    )
}

Ref를 변수로서 사용

  • timer을 useRef로 선언 후, 변수처럼 사용.
  • 각 컴포넌트에 의존하는 ref를 가지게 됨.
  • state는 컴포넌트 함수가 재실행되면 값이 유실되지만, ref는 컴포넌트 함수가 재실행되어도 변함 없음.
  • ref를 변화시켜도 컴포넌트 함수의 재실행이 되지 않음.
import { useState, useRef } from "react";

export default function TimerChallenge ({ title, targetTime }) {
    const timer = useRef();
    const [timerStarted, setTimerStarted] = useState(false);
    const [timerExpired, setTimerExpired] = useState(false);

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

      setTimerExpired(false);
      setTimerStarted(true);
    }

    const handleStop = () => {
        setTimerStarted(false); 
        clearTimeout(timer.current);
    }

    return (
        <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 Challenge' : 'Start Challenge'}
                </button>
            </p>
            <p className={timerStarted ? 'active' : undefined}>
                {timerStarted ? 'Time is running...' : 'Timer inactive'}
            </p>
        </section>
    )
}

  • 결과 모달 Component 생성
export default function ResultModal({result, targetTime}) {
  return <dialog className="result-modal">
    <h2>You {result}</h2>
    <p>
        The target time was <strong>{targetTime}</strong> seconds.
    </p>
    <p>
        You stopped the timer with <strong>X seconds</strong> left.
    </p>
    <form method="dialog">
        <button>Close</button>
    </form>
  </dialog>

}

HTML dialog Element

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

export default function TimerChallenge ({ title, targetTime }) {
    const timer = useRef();
    const [timerStarted, setTimerStarted] = useState(false);
    const [timerExpired, setTimerExpired] = useState(false);

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

      setTimerExpired(false);
      setTimerStarted(true);
    }

    const handleStop = () => {
        setTimerStarted(false); 
        clearTimeout(timer.current);
    }

    return (
        <>
            {timerExpired && <ResultModal targetTime={targetTime} result="lost"/>}
            <section className="challenge">
                <h2>{title}</h2>
                <p className="challenge-time">
                {targetTime} Second{targetTime > 1 ? 's' : ''}
                </p>
                <p>
                    <button onClick={timerStarted ? handleStop : handleStart}>
                        {timerStarted ? 'Stop Challenge' : 'Start Challenge'}
                    </button>
                </p>
                <p className={timerStarted ? 'active' : undefined}>
                    {timerStarted ? 'Time is running...' : 'Timer inactive'}
                </p>
            </section>
        </>
    )
}
// ResultModal.jsx
export default function ResultModal({result, targetTime}) {
  return <dialog className="result-modal" open>
    <h2>You {result}</h2>
    <p>
        The target time was <strong>{targetTime} seconds.</strong> 
    </p>
    <p>
        You stopped the timer with <strong>X seconds</strong> left.
    </p>
    <form method="dialog">
        <button>Close</button>
    </form>
  </dialog>

}

Ref 사용

  • dialog 태그의 backdrop 어트리뷰트를 사용하기 위해서는 위와 같이 open인 상태에서는 접근 X.
  • 이 때, ref를 이용해서 dialog DOM에 접근해서 사용 가능함.
  • showModal Method를 사용하여 보이게 함.
  • showModal로 dialog를 열여주면, backdrop(내장 기능) 활용 가능.
  • 아래와 같이 props로 ref를 다른 컴포넌트에 전달하는 것은 불가능
import { useState, useRef } from "react";
import ResultModal from "./ResultModal";

export default function TimerChallenge ({ title, targetTime }) {
    const timer = useRef();
    const dialog = useRef();
    const [timerStarted, setTimerStarted] = useState(false);
    const [timerExpired, setTimerExpired] = useState(false);

    const handleStart = () => {
      timer.current = setTimeout(() => {
        setTimerExpired(true);
        dialog.current.showModal(); // ✅ dialog.showModal Method
      }, targetTime * 1000);

      setTimerExpired(false);
      setTimerStarted(true);
    }

    const handleStop = () => {
        setTimerStarted(false); 
        clearTimeout(timer.current);
    }

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

forwardRef

  • 컴포넌트 함수를 감싸는 함수.
  • 자식 컴포넌트에 ref를 넘겨주려면, 자식 컴포넌트를 forwardRef()로 감싸줘야 사용 가능함.
  • 함수의 두번째 인자로 ref를 받음. (부모에서 ref로 넘겨준 값)
import { forwardRef } from "react";

const ResultModal = React.forwardRef(({result, targetTime}, ref) => {
  return <dialog ref={ref} className="result-modal">
    <h2>You {result}</h2>
    <p>
        The target time was <strong>{targetTime} seconds.</strong> 
    </p>
    <p>
        You stopped the timer with <strong>X seconds</strong> left.
    </p>
    <form method="dialog">
        <button>Close</button>
    </form>
  </dialog>
})

export default ResultModal;

useImperativeHandle Hook

useImperativeHandle

  • child component의 상태 변경을 parent component에서 하는 경우
  • child component의 핸들러를 parent component에서 호출해야 하는 경우
  • 자식 컴포넌트에서는 React.forwardRef로 부모 컴포넌트로부터 ref를 전달받아야 함.
    -> 참고 문서
  • 프로퍼티, 메서드 정의
  • 해당 컴포넌트에서 바깥으로 이동
  • 보통은 props를 사용하는 경우가 많지만, 컴포넌트를 안전하고 재사용성있게 만들기 위해 사용.
// ResultModal.jsx (Child Component)
import {forwardRef, useRef, useImperativeHandle} from "react";

const ResultModal = React.forwardRef(({result, targetTime}, ref) => {
  const dialog = useRef();

  // ✅ useImperativeHandle 
  useImperativeHandle(ref, () => {
    return {
        open() {
            dialog.current.showModal();
        }
    };
  });
  
  return <dialog ref={ref} className="result-modal">
    <h2>You {result}</h2>
    <p>
        The target time was <strong>{targetTime} seconds.</strong> 
    </p>
    <p>
        You stopped the timer with <strong>X seconds</strong> left.
    </p>
    <form method="dialog">
        <button>Close</button>
    </form>
  </dialog>
})

export default ResultModal;

자식 컴포넌트에서 useImperativeHandle(ref, () => {}) 를 해주면
부모 컴포넌트에서 해당 객체를 참고하여 자식 컴포넌트의 상태를 변경할 수 있음.

// TimerChallenge.jsx (Parent Component)

...

// 기존에는 dialog.current.showModal()로 사용했음
dialog.current.open();

Practice Project 고도화

  • 성공 시 점수 계산해서 보여주기 (남은 시간 대비)
  • setInterval 함수를 사용해야 함.
// TimerChallenge.jsx
import { useState, useRef } from "react";
import ResultModal from "./ResultModal";

export default function TimerChallenge ({ title, targetTime }) {
    const dialog = useRef();
    
    const [timeRemaining, setTimeRemaining] = useState(targetTime * 1000);

    const timerIsActive = timeRemaining > 0 && timeRemaining < targetTime * 1000; 

    if (timeRemaining <= 0) { // 시간 초과 시 
        clearInterval(timer.current);
        setTimeRemaining(targetTime * 1000);
        dialog.current.open();
    }

    const handleStart = () => {
      timer.current = setInterval(() => {
        setTimeRemaining(prevTimeRemaining => prevTimeRemaining - 10);
      }, 10);
    }

    const handleStop = () => {
        setTimerStarted(false); 
        clearInterval(timer.current);
    }

    return (
        <>
            <ResultModal ref={dialog} targetTime={targetTime} result="lost"/>
            <section className="challenge">
                <h2>{title}</h2>
                <p className="challenge-time">
                {targetTime} Second{targetTime > 1 ? 's' : ''}
                </p>
                <p>
                    <button onClick={timerIsActive ? handleStop : handleStart}>
                        {timerIsActive ? 'Stop Challenge' : 'Start Challenge'}
                    </button>
                </p>
                <p className={timerIsActive ? 'active' : undefined}>
                    {timerIsActive ? 'Time is running...' : 'Timer inactive'}
                </p>
            </section>
        </>
    )
}
// ResultModal.jsx
import {forwardRef, useRef, useImperativeHandle} from "react";

const ResultModal = React.forwardRef(({ targetTime, timeRemaining }, ref) => {
  const dialog = useRef();
  const userLost = timeRemaining <= 0; 
  const formattedRemainingTime = (timeRemaining / 1000).toFixed(2);

  useImperativeHandle(ref, () => {
    return {
        open() {
            dialog.current.showModal();
        }
    };
  });
  
  return <dialog ref={ref} className="result-modal">
    {userLost && <h2>You Lost</h2>}
    <p>
        The target time was <strong>{targetTime} seconds.</strong> 
    </p>
    <p>
        You stopped the timer with <strong>{formattedRemainingTime} seconds</strong> left.
    </p>
    <form method="dialog">
        <button>Close</button>
    </form>
  </dialog>
})

export default ResultModal;

Error

  • 지금 코드에서는 시간이 초과되면, clearInterval 하고나서 timeRemaining을 리셋시킨다.
  • 그래서 lost 모달이 뜨지 않고, 1초가 남았다는 모달이 뜨게 된다.
  • 모달의 Close를 눌렀을 때 시간이 리셋되도록 수정해야 한다.
if (timeRemaining <= 0) {
  clearInterval(timer.current);
  setTimeRemaining(targetTime * 1000); // Error Logic! (리셋을 여가서 해주면 X)
  dialog.current.open();
}
const handleReset = () => {
  setTimeRemaining(targetTime * 1000); 
}

...

  
  <ResultModal 
    ref={dialog} 
    targetTime={targetTime} 
    timeRemaining={timeRemaining} 
    onReset={handleReset}
    />
import {forwardRef, useRef, useImperativeHandle} from "react";

const ResultModal = React.forwardRef(({ targetTime, timeRemaining, onReset }, ref) => {
  const dialog = useRef();
  const userLost = timeRemaining <= 0; 
  const formattedRemainingTime = (timeRemaining / 1000).toFixed(2);

  useImperativeHandle(ref, () => {
    return {
        open() {
            dialog.current.showModal();
        }
    };
  });
  
  return <dialog ref={ref} className="result-modal">
    {userLost && <h2>You Lost</h2>}
    <p>
        The target time was <strong>{targetTime} seconds.</strong> 
    </p>
    <p>
        You stopped the timer with <strong>{formattedRemainingTime} seconds</strong> left.
    </p>
    <form method="dialog" onSubmit={onReset}>
        <button>Close</button>
    </form>
  </dialog>
})

export default ResultModal;

점수 계산

  • 남은 시간이 적을수록 높은 점수가 계산되도록 고도화.
// ResultModal.jsx

import {forwardRef, useRef, useImperativeHandle} from "react";

const ResultModal = React.forwardRef(({ targetTime, timeRemaining, onReset }, ref) => {
  const dialog = useRef();
  const userLost = timeRemaining <= 0; 
  const formattedRemainingTime = (timeRemaining / 1000).toFixed(2);
  const score = Math.round((1 - timeRemaining / (targetTime * 1000)) * 100);

  useImperativeHandle(ref, () => {
    return {
        open() {
            dialog.current.showModal();
        }
    };
  });
  
  return <dialog ref={ref} 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</strong> left.
    </p>
    <form method="dialog" onSubmit={onReset}>
        <button>Close</button>
    </form>
  </dialog>
})

export default ResultModal;

dialog 모달을 ESC로 닫기

  • 버튼으로 닫으면 onReset이 트리거됨.
  • 그러나, 버튼이 아닌 ESC 키로 닫을 때도 onReset이 트리거되도록 설정해야 함.
  • dialog Element의 onClose에 onReset을 바인딩해주면 됨.
<dialog ref={dialog} onClose={onReset}>
  ... 
</dialog>

Portals

  • Modal은 마크업상 최상단에 App과 별개로 있어야 함.
  • JSX 를 createPortal로 감싸서 두번째 인자로 HTML Element를 넣어주면 됨.

createPortal

  • react가 아닌 react-dom의 메서드임.
  • 두 번째 인자로 HTML Element를 넘겨받음. (index.html 내에 존재하는 요소여야 함.)
import { forwardRef, useRef, useImperativeHandle } from "react";
import { createPortal } from 'react-dom';

const ResultModal = React.forwardRef(({ targetTime, timeRemaining, onReset }, ref) => {
  
  ...
  
  return createPortal(<dialog ref={ref} className="result-modal" onClose={onReset}>
    {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</strong> left.
    </p>
    <form method="dialog" onSubmit={onReset}>
        <button>Close</button>
    </form>
  </dialog>, document.getElementById('modal'))
})

export default ResultModal;
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글