티스토리 뷰

반응형

react로 DOM에 접근하여 스크롤 이벤트, 포커싱 등을 할 수 있는 방법이 바로 ref를 사용하는 방법이다. 기본적인 React에서 부모 자식 컴포넌트의 상호작용은 props로 한다. 그리고 자식을 수정하기 위해서는 props의 값 변경을 통해 수행한다. 수정을 하는 것이 자식 컴포넌트가 아닌 DOM Element나 React Element인 경우도 있을 것이다. 이러한 경우에 ref를 통해 render 되는 html 태그(DOM Node, React Element)에 접근하는 것이 가능하다. 

 

또한, Ref 자체가 단방향 흐름인 React에서 어떠한 오아시스 같은 역할을 하는지도 알아보자.

 

 

 

1. Ref 사용 시기

공식문서는 아래와 같은 시기에 사용을 권장한다.

  1. focus 하는 경우
  2. input 태그 선택
  3. 미디어 재생 관리
  4. 애니메이션을 직접 실행
  5. 서드 파티 DOM 라이브러리를 React와 함께 사용
  6. timeout IDs 저장

보통은 DOM 조작시 많이 사용 할 것이다. ref 사용 시 주의 할 점은 상태가 변화 할 때 사용하는 것을 지양해야한다. 상태변환은 ref가 아니라 useState를 사용하자. 

 

 

2. Ref 특징

변화 시 컴포넌트를 re-render 하지 않는다. useState와 비슷하게, 초기값으로 string, number, object, even function들이 들어가는 것이 가능하다. 주의해야할 점은, rendering 동안에 ref를 read/write하면 안된다. react는 render 시 ref.current의 변화를 알지 못하기 때문에, 예상치 못한 에러가 발생할 수 있다. 아래의 예는 사용하면 안되는 시기이다.

 

function MyComponent() {
  // during rendering
  myRef.current = 123;
  // during rendering
  return <h1>{myOtherRef.current}</h1>;
}

 

위의 경우는 사용하면 안되고 아래와 같이 event handler 내부나 useEffect 내부에서 사용한다.

 

function MyComponent() {
  // ...
  useEffect(() => {
    // read or write refs in effects
    myRef.current = 123;
  });
  
  function handleClick() {
    // read or write refs in event handlers
    doSomething(myOtherRef.current);
  }
  // ...

}

 

3. useState와 의 차이점

 ref는 일반적인 Javascript의 객체이다. 따라서 current 프로퍼티로 접근하여 읽고 쓰는 것이 가능하다. useState의 상태값의 변화에 따라 re-render가 일어나지만, ref는 re-render가 일어나지 않는다.

 

4.  ref 사용법

사용법은 간단하다. useRef Hook을 사용하면 된다.

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

 

useRef(0)에 초기값을 0을 넣은 것을 알 수 있다. 기본적으로 ref는 아래와 같은 return을 한다.

 

{ 
  current: 0 
}

 

 

위의 말을 쉽게 적으면, 우리는 초기값에 접근을 하기위해서는 ref가 아니라 ref.current로 접근을 해야한다는 말이다. 이러한 구조는 의도적으로 mutable하게 만든 것이다. 그래서 값을 읽고 쓰는 것을 가능하게 하고 React에서 감시 할 수 없게 만든다.

 

 

5. Ref, useState 동시 사용법

위에서 언급한 것과 같이, 렌더가 필요하면 useState를 사용하고 그렇지 않고 에벤트 핸들러에만 필요하다면 아래와 같이 ref를 사용하는 것이 더 효율적이다.

 

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

 

타이머이다. 1ms 마다 시간이 증가 될 수 있도록, setInterval을 사용하여 기본적으로 구현을 했다. 그리고 setInterval을 멈추기 위한 id값을 받을 때 ref를 사용 한 것을 알 수 있다. 이때 핵심은, ref는 useState 변경으로 인해 re-render가 일어나도 값변경이 없다. mount 될 때 ref가 생성되고, unmount 시 ref는 null이 된다.

 

ref를 HTML 태그로 넣는 다면 ref.current.focus() 와 같이 간단하게 DOM 조작이 가능해 진다.

 

 

6. 심화

사실 useRef는 useState 상위 개념으로 구현되어 있다.

 

function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

 

처음 useRef를 렌더하면, { current : initialValue }를 리턴한다. 그리고 unused가 사용되지 않는다. 왜냐하면, 항상 같은 객체를 return해주기 때문이다. 따라서 ref 자체를 setter가 없는 일반적인 상태 변수라 생각하면 된다.

 

 

7. Ref로 DOM 조작하기

기본적으로 리액트는 자동으로 렌더의 결과와 DOM을 일치키기 때문에 DOM 자체를 조작할 필요는 거의 없다. 그러나 아래의 경우는 React 자체적으로 할 수 없기 때문에, ref를 이용해서 핸들링 해야한다.

 

  1. focus
  2. scroll
  3. size 측정
  4. position 측정

사용법은 위와 비슷하다. DOM을 접근하기 위해서는 우선은 아래와 같이  useRef를 import 한다.

 

import { useRef } from 'react';

 

component 내부에서 ref를 선언한다.

 

const myRef = useRef(null);

 

마지막으로 DOM node에 ref 속성을 아래와 같이 넣어주면 된다.

 

<div ref={myRef}>

 

위의 예를 보자. 기본적으로 위에서 설명한 것 처럼 useRef는 current이라는 단일 프로퍼티를 가진 객체이다. 위에서 우리는 myRef.current에 null을 초기 값으로 넣었다. 그리고 React가 DOM을 만들 때, myRef.current는 <div> node를 참조한다. 이때, myRef.current를 이용해서 DOM node에 접근가능하다. myRef.current는 아래와 같이 이벤트 핸들러나 브라우저 API도 일반 element 처럼 사용가능하다. 쉽게 말해 브라우저 API의 예는 아래와 같다.

 

myRef.current.scrollIntoView();

 

 

8. ref로 focusing 구현하기

 

버튼을 클릭하면 input 태그를 포커싱하는 예이다.

 

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

<input ref={inputRef}>를 활용하여 기본적으로 input의 DOM node를 react로 핸들링하게 한것이다. 그리고 inputRef.current.focus()를 활용하여 포커싱을 하는 예이다. ref는 React가 아닌 외부의 것을 저장하는데 사용되는 것을 확인 할 수 있다. 그리고 rendering이 되어도 상태가 유지됨을 알 수 있다.

 

8. ref로 Scrolling 구현하기

이번엔 브라우저 APIs 중에 scrollIntoView()를 활용한 예이다. 사실 위와 비슷하다.

 

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

 

크게 어려운 로직은 없다. 

 

 

9. ref로 여러 목록 참고하기(반복문)

위의 scroll 같은 경우는 정해진 수의 ref를 만들었다. 그러나 특정 갯수가 정해지지 않는 경우라면, map을 써서 아래와 같이 구현을 할수도 있다.

 

<ul>
  {items.map((item) => {
    // Doesn't work!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

 

당연하게도 돌아가지 않는다. 왜냐하면, ref 는 component 기준 최상위에서만 호출되어야 한다. 즉, 루프, 조건, map같은 경우에 useRef를 호출 할 수 없다. 해결책은 ref 속성으로 함수(ref callback)를 넣는 것이다. 아래의 예를 보자.

 

import { useRef } from 'react';

export default function CatFriends() {
  const itemsRef = useRef(null);

  function scrollToId(itemId) {
    const map = getMap();
    const node = map.get(itemId);
    node.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Initialize the Map on first usage.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToId(0)}>
          Tom
        </button>
        <button onClick={() => scrollToId(5)}>
          Maru
        </button>
        <button onClick={() => scrollToId(9)}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          {catList.map(cat => (
            <li
              key={cat.id}
              ref={(node) => {
                const map = getMap();
                if (node) {
                  map.set(cat.id, node);
                } else {
                  map.delete(cat.id);
                }
              }}
            >
              <img
                src={cat.imageUrl}
                alt={'Cat #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

const catList = [];
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i
  });
}

 

 

 간단한 로직이다. itemsRef.currentnew Map()을 넣어서 rendering 시에도 상태를 유지하도록 하고, itensRef.current를 가지고 와서 cat.id와 ref callback 함수의 DOM node 인자를 활용한다. ref callback function은 아래와 같다.

 

<div ref={(node) => console.log(node)} />

 

위의 ref 의 node는 매 render마다 다르다는 것을 알아야 한다. 왜냐하면, component가 re-render 되면, 이전 함수의 인자는 null이 되고 새로운 함수는 DOM node가 된다. 따라서 위 의 예제와 같이 map method로 위와 같이 적어도 되는 것이다.

 

 

10. 다른 컴포넌트에 ref 붙이기(React.forwardRef())

새롭게 만든 <MyInput /> 과 같은 컴포넌트에 ref를 붙이고 싶다. 하지만 아래와 같이 구현을 하면, 내가 원하는 DOM node가 아닌 null값이 잡힌다.

 

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

포커싱을 하는 예시이다. 그러나 실행해보면, 아래와 같은 에러가 발생한다.

 

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

 

구현하다가 어딘가에서 많이 본 에러 같은 느낌이 들 수도 있다. 기본적으로 React는 각 component는 다른 component의 DOM에 접근하는것을 허용하지 않는다. 물론 props로 자식 component로 DOM 접근도 불가능하다. 그러나 forwardRef API를 사용하면 DOM 노출을 허용할 수 있다. 에러를 자세히 보면 React.forwardRef()를 사용하라고 추천해 주고 있다. forwardRef는 기본적으로 ref가 props로 내려주는게 안되기 때문에 추가로 사용을 해줘야 한다. 사용법은 아래와 같다.

 

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

 

<MyInput ref={inputRef} />는 기본적으로 React에 해당 컴포넌트의 DOM node를 inputRef.current에 넣도록 한다. forwardRef의 두번째 인자인 ref로 inputRef를 받는다. 그리고 MyInput은 받은 ref를 <input> 태그 로 넘긴다. 전체코드는 아래와 같다.

 

 

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

기본적으로 low-level component(button 태그, input 태그 등등)은 ref를 내려주어 DOM node를 노출하는 것이 일반적이다. 그러나 high-level component(form, list, page section 등등)은 예상못한 의존성을 피하기 위해 DOM node를 노출하지 않는다.

 

 

11. 심화

우선 아래의 코드를 보자. handle 함수 하나만 가지고 왔다.

 

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );
  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

 

handleAdd를 보면, setText반영 후 setTodos로 업데이트가 되고 가장 아래부분에 업데이트가 된 위치로 스크롤이 내려가야한다. 그러나, useState의 set부분이 동기적으로 바로 적용이 안된다. 이러한 경우는 아래와 같이 flushSync를 사용해주면된다.

 

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

 

 

반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함