develop

useState의 비동기 문제 - useRef

crab. 2023. 4. 7. 12:49

해결방법 final 2. useRef 사용

import { useEffect, useRef } from "react";
import { useState } from "react";

export default function StateTestT() {
  const [memberId, setMemberId] = useState(1);
  const memberIdRef = useRef();
  memberIdRef.current = memberId;
  const updateMemberId = () => {
    // (1)
    memberIdRef.current = memberIdRef.current + 1;
    console.log(memberIdRef.current);
    memberIdRef.current = memberIdRef.current + 1;
    console.log(memberIdRef.current);
    memberIdRef.current = memberIdRef.current + 1;
    console.log(memberIdRef.current);

    // (2)
    validateMemberId();
  };

  const validateMemberId = () => {
    memberIdRef.current = memberIdRef.current + 1;
    console.log(memberIdRef.current);
    memberIdRef.current = memberIdRef.current + 1;
    console.log(memberIdRef.current);

    if (memberId > 3) {
      console.log("5 이상");
      return 0;
    }
  };

  // useEffect(() => {
  //   // setMemberId(memberId => memberId + 1);
  //   // setMemberId(memberId => memberId + 1);
  //   console.log(memberId);
  //   if (memberId > 3) {
  //     console.log("3 이상");
  //   }
  // }, [memberId]);

  return (
    <>
      <div onClick={updateMemberId}>test</div>
    </>
  );
}
>> 2
>> 3
>> 4
>> 5
>> 6
  • 좀 더 이해를 돕기 위한 다른 예시도 있다.
import { useRef, useState } from "react";

export default function StateTest() {
  const [memberId, setMemberId] = useState("");
  const memberIdRef = useRef();

  const updateMemberId = e => {
    console.log(e.target.value);
    memberIdRef.current = e.target.value;
    // (1)
    setMemberId(e.target.value);
    // (2)
    console.log(memberId);
    console.log(memberIdRef.current);
    validateMemberId();
  };

  const validateMemberId = () => {
    console.log(memberId);
    console.log(memberIdRef.current);
  };

  return (
    <>
      <input
        onChange={e => {
          updateMemberId(e);
        }}
      />
    </>
  );
}
>>a
>>
>>a
>>
>>a
  • 코드를 보면 이런 생각이 들 것이다.
  • ‘이건 useState를 쓰는게 아니잖아요?’
  • 맞다.
  • 이 방법은 결국
    • 값을 state, useRef()이 두 곳에 저장한다는것이다.
    • 비동기로 인해 문제가 발생하는 지점에서는 state대신 useRef()에 저장해놓은 값을 사용해서 이 문제를 해결할 수 있다.
    • 고전적인 방법들 중에서는 가장 훌륭하지만… 이걸 정리했던 사람은 뭔가 아쉽다고 한다..
  • 2번째 예시는 더 바꿀 수도 있다.
import { useRef, useState } from "react";

export default function StateTest() {
  const [memberId, setMemberId] = useState("");
  const memberIdRef = useRef();

  const updateMemberId = e => {
    console.log(e.target.value);
    // memberIdRef.current = e.target.value;
    // (1)
    setMemberId(e.target.value);
    // (2)
    console.log(memberId);
    console.log(memberIdRef.current.value);
    validateMemberId();
  };

  const validateMemberId = () => {
    console.log(memberId);
    console.log(memberIdRef.current.value);
  };

  return (
    <>
      <input
        ref={memberIdRef}
        onChange={e => {
          updateMemberId(e);
        }}
      />
    </>
  );
}
>>a
>>
>>a
>>
>>a
  • 물론 결과는 같다.
  • 내가 생각했을 때 제일 깔끔한 방법은 이 방법이다.
  • 그럼 이제 이 useRef에 대해 정리하고 어떻게 이런 결과를 낼 수 있는지 알아보자

useRef 사용법(렌더링과 관련이 없는 변수를 관리)

상태 변경 -> 컴포넌트 재 랜더링

  • React 컴포넌트는 기본적으로 내부 상태(state)가 변할 때 마다 다시 랜더링(rendering)이 된다.
  • 예를 들어, 아래 <Counter/>컴포넌트의 버튼을 5번 클릭하면 count상태값은 5번 바뀌게 된다.
import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  console.log(`랜더링... count: ${count}`);

  return (
    <>
      <p>{count}번 클릭하셨습니다.</p>
      <button onClick={() => setCount(count + 1)}>클릭</button>
    </>
  );
}
  • 브라우저 콘솔을 확인해보면, 5번의 로그가 찍히는 것을 볼 수 있는데 이를 통해, <Counter/> 컴포넌트 함수는 count 상태가 바뀔 때 마다 호출되는 것을 알 수 있다.
랜더링... count: 1
랜더링... count: 2
랜더링... count: 3
랜더링... count: 4
랜더링... count: 5
  • 컴포넌트 함수가 다시 호출이 된다는 것은 함수 내부의 변수들이 모두 다시 초기화가 되고 함수의 모든 로직이 다시 실행된다는 것을 의미한다.

다시 랜더링 되어도 동일한 참조값을 유지하려면?

  • 우리는 대부분의 경우, 위와 같이 상태가 변할 때 마다 React 컴포넌트 함수가 호출되어 화면이 갱신되기를 바란다.
  • 하지만 그에 따른 부작용으로 함수 내부의 변수들이 기존에 저장하고 있는 값들을 잃어버리고 초기화되는데
  • 간혹 다시 랜더링이 되더라도 기존에 참조하고 있던 컴포넌트 함수 내의 값이 그대로 보존되야 하는 경우가 있다.
  • 예를 들어, 카운팅이 자동으로 되도록 useEffect 훅(hook) 함수를 이용하여 위의 컴포넌트를 수정해보자.
import React, { useState, useEffect } from "react";

function AutoCounter() {
  const [count, setCount] = useState(0);
  console.log(`랜더링... count: ${count}`);

  useEffect(() => {
    const intervalId = setInterval(() => setCount((count) => count + 1), 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <p>자동 카운트: {count}</p>;
}
  • 자, 여기서 만약에 카운트를 자동으로 시작하지 않고 버튼을 이용하여 시작하고 정지하고 싶다면 어떻게 해야 할까?
import React, { useState, useEffect } from "react";

function ManualCounter() {
  const [count, setCount] = useState(0);

  let intervalId;

  const startCounter = () => {
    // 💥 매번 새로운 값 할당
    intervalId = setInterval(() => setCount((count) => count + 1), 1000);
  };
  const stopCounter = () => {
    clearInterval(intervalId);
  };

  return (
    <>
      <p>자동 카운트: {count}</p>
      <button onClick={startCounter}>시작</button>
      <button onClick={stopCounter}>정지</button>
    </>
  );
}
  • 여기서 가장 큰 걸림돌은 안에서 선언된 intervalId변수를 startCounter()함수와 stopCounter()함수가 공유할 수 있도록 해줘야 한다는 것이다.
  • 그럴려면 intervalId변수를 두 함수 밖에서 선언해야하는데 그럴 경우, count상태값이 바뀔 때 마다 컴포넌트 함수가 호출되어 intervalId 도 매번 새로운 값으로 바뀔 것이다.
  • 따라서, 브라우저 메모리에는 미처 정리되지 못한 intervalId들이 1초에 하나식 쌓여나갈 것이다. 💥
  • 클래스를 이용해서 React 컴포넌트를 작성할 시절에는, 이와 같은 문제를 해결하는 가장 명료한 방법은 인스턴스(instance) 변수에 이러한 값들을 저장하는 것이었다.
  • 하지만 최근처럼 대부분 함수를 이용해서 React 컴포넌트를 작성할 때는 일반적으로 useRef훅(hook) 함수를 사용해서 이러한 문제를 해결한다.

useRef 사용하기

  • useRef 함수는 current 속성을 가지고 있는 객체를 반환하는데, 인자로 넘어온 초기값을 current 속성에 할당한다.
  • 이 current 속성은 값을 변경해도 상태를 변경할 때 처럼 React 컴포넌트가 다시 랜더링되지 않는다.
  • React 컴포넌트가 다시 랜더링될 때도 마찬가지로 이 current 속성의 값이 유실되지 않는다.
  • useRef 훅 함수가 반환하는 객체의 이러한 독특한 성질을 이용하여 startCounter()와 stopCounter() 함수를 구현해보자.
import React, { useState, useRef } from "react";

function ManualCounter() {
  const [count, setCount] = useState(0);
  const intervalId = useRef(null);
  console.log(`랜더링... count: ${count}`);

  const startCounter = () => {
    intervalId.current = setInterval(
      () => setCount((count) => count + 1),
      1000
    );
    console.log(`시작... intervalId: ${intervalId.current}`);
  };

  const stopCounter = () => {
    clearInterval(intervalId.current);
    console.log(`정지... intervalId: ${intervalId.current}`);
  };

  return (
    <>
      <p>자동 카운트: {count}</p>
      <button onClick={startCounter}>시작</button>
      <button onClick={stopCounter}>정지</button>
    </>
  );
}
  • 위 컴포넌트를 실행하면 콘솔에 아래와 같은 로그가 찍히는데
  • 시작 버튼을 누르면 새로운 intervalId가 생성되고, 정지 버튼을 누르면 기존 intervalId가 정리되는 것을 확인할 수 있다.
랜더링... count: 0
시작... intervalId: 17
랜더링... count: 1
랜더링... count: 2
랜더링... count: 3
랜더링... count: 4
랜더링... count: 5
정지... intervalId: 17
시작... intervalId: 32
랜더링... count: 6
랜더링... count: 7
랜더링... count: 8
정지... intervalId: 32
  • useRef훅 함수가 다른 React Hooks에 비해서 사용 빈도가 떨어지는 이유는 아무래도 살펴본 것처럼 용도가 극히 제한적이기 때문일 것이다.
  • useRef훅 함수를 사용하는 또 다른 경우가 있는데, DOM 노드나 React 엘리먼트에 직접 접근하기 위해서이다.

useRef 사용법(DOM선택하기)

  • HTML과 JS를 사용할때, DOM에 접근하거나 선택할 일이 있으면, [getElementById](<https://developer.mozilla.org/ko/docs/Web/API/Document/getElementById>)나 [querySelector](<https://developer.mozilla.org/ko/docs/Web/API/Document/querySelector>) 등의 DOM selector 함수를 사용한다.
  • React를 사용하는 경우에서도 특정 DOM에 접근할 일이 있다.
    • 특정 엘리먼트의 크기/위치를 알아낼 때,
    • 스크롤 바의 위치를 가져오거나 설정할 때,
    • focus를 설정해야할 때,
    • Video.js(비디오 관련 라이브러리)등 HTML5 비디오 관련 라이브러리를 사용할 때,
    • D3, Chart.js와 같은 그래프 관련 라이브러리를 사용하게 될 때> 특정 DOM에 라이브러리 설정해야 함
  • 이럴때, React에서는 ref라는 것을 사용하고,함수형 컴포넌트에서는 useRef 라는 훅 함수를 사용한다.
  • 클래스형 컴포넌트에서는 React.createRef()라는 함수를 사용.
  • 여기선 함수형 컴포넌트일때 사용하는 useRef 함수만 알아본다.

예시

  • '초기화' 버튼을 누르면 '이름' input 태그로 포커스가 이동하게 변경해보자.
  • 이렇게 바꾸기 위해서는 React 자체적인 기술로는 뭔가 할 수 있는게 없어, 직접 DOM에 접근을 해야 한다.
  • DOM에 직접 접근하기 위해서는 먼저 useRef 함수를 불러온다.
import React, { useState, useRef } from 'react';
//새로 'useRef' 함수 불러옴
  • 그리고 코드 상단에
const nameInput = useRef();
  • 라고 nameInput 객체를 선언.
  • 그리고 만들어진 nameInput 객체를 우리가 선택해주고 싶은 DOM에 설정해준다.
<input
  name="name"
  placeholder="이름"
  onChange={onChange}
  value={name}
  ref={nameInput} //이렇게 설정!
/>
  • 여기까지 하고 나면 우리가 원하는 DOM에 직접 접근할 수 있는데,
const onReset = () => {
  setInputs({
    name: '',
    nickname: '',
  });

  nameInput.current.focus(); //이렇게 접근할 수 있다.
};
  • 위와 같이 쓸 수 있다.
  • nameInput.current.focus(); 이 문장을 해석해보면,
  • nameInput.current 까지가 해당 DOM을 가리키게 되고,
  • 그 다음 DOM API 중 하나인 focus() 함수를 사용하여 우리가 원하는 기능을 구현할 수 있다.

다시 돌아와서

  • 첫번째 예시 코드로 쓴 게 렌더링과 관련이 없는 변수를 관리하는 useRef의 첫번째 기능을 쓴 것이고
  • 두번째 예시 코드로 쓴 게 DOM을 선택하는 useRef의 두번째 기능을 쓴 것이다.
  • 무엇하나 중요하지 않은 내용이 없었으며 이 useState의 비동기문제의 원인과 해결을 잘 이해한다면 react의 기초와 핵심에 한 발자국 더 가까이 다가갔다고 봐도 되지 않을까 싶다.(아직은 많이 부족하지만..)