React 컴포넌트에 ref props 전달하기 (feat. forwardRef, useImperativeHandle)


2021.08.18react

react ref props

React에서 ref 속성(props)이 가장 많이 사용하는 용도는 DOM 엘리먼트를 참조하는 경우일 것입니다. HTML 엘리먼트에 대해 refprops로 넘겨주어 DOM 엘리먼트를 담거나, 콜백 함수를 넘겨주어 DOM 엘리먼트를 매개변수로 접근할 수 있습니다. 하지만 ref의 기능은 그것뿐만이 아닙니다.

Refs provide a way to access DOM nodes or React elements created in the render method.
공식문서(en) 공식문서(ko)

리액트 공식문서에서는 ref를 통해 DOM 노드뿐만 아니라 React 엘리먼트(DOM 노드 포함)에 접근할 수 있다고 말합니다. 이번 포스트에서는 React 엘리먼트를 다루는 ref 속성에 대해 알아보겠습니다.


DOM 조작을 위한 ref 전달

import { useEffect, useRef } from "react"

function App() {
  const inputRef = useRef()

  useEffect(() => {
    console.log(inputRef) // { current: <input type="text"> }
  })

  return (
    <div>
      <input ref={inputRef} />
    </div>
  )
}

export default App

기본적으로 jsx로 DOM 엘리먼트를 작성할 때 useRef로 호출된 값을 ref 속성으로 넘겨주면 해당 ref 객체에 DOM 엘리먼트가 할당됩니다. 만약 input을 구현하는 컴포넌트 내부의 DOM 엘리먼트에 접근하려면 어떻게 해야 할까요? 다음 코드처럼 컴포넌트를 작성합니다.

import { useEffect, useRef } from "react"

function MyInput(props) {
  const { ref } = props

  useEffect(() => {
    console.log("ref:", ref) // ref: undefined
  })

  return <input ref={ref} />
}

function App() {
  const inputRef = useRef()

  useEffect(() => {
    console.log("inputRef:", inputRef) // inputRef: { current: undefined }
  })

  return (
    <div>
      <MyInput ref={inputRef} />
    </div>
  )
}

export default App

직접 작성한 MyInput 컴포넌트에 ref 속성으로 ref 객체를 전달했음에도 MyInput 컴포넌트 내부에서는 ref라는 이름으로 전달된 propsundefined로 전달되는 것을 확인할 수 있습니다. 공식문서에서 말하는 것처럼 ref 속성은 DOM 엘리먼트React 엘리먼트를 참조하기 위한 속성이기 때문입니다.(Special Props Warning)

컴포넌트 내부의 엘리먼트를 참조하기 위해서는 어떻게 해야 할까요?

1. ref가 아닌 inputRef 속성으로 전달

ref는 컴포넌트 props 중에서 일종의 예약어이기 때문에 사용할 수 없습니다. 그래서 다른 이름의 props로 전달하면 됩니다.

import { useEffect, useRef } from "react"

function MyInput(props) {
  const { myInputRef } = props

  useEffect(() => {
    console.log("myInputRef:", myInputRef) // myInputRef: {current: <input type="text">}
  })

  return <input ref={myInputRef} />
}

function App() {
  const inputRef = useRef()

  useEffect(() => {
    console.log("inputRef:", inputRef) // inputRef: {current: <input type="text">}
    inputRef.current.focus() // MyInput 컴포넌트의 input 엘리먼트에 focus 됨
  })

  return (
    <div>
      <MyInput myInputRef={inputRef} />
    </div>
  )
}

export default App

컴포넌트에 전달하는 props의 이름만 바꾸는 것으로 ref 객체를 전달하여 컴포넌트 내부의 엘리먼트에 접근할 수 있습니다. 하지만 이렇게 컴포넌트를 작성하게 되면 input 엘리먼트와 거의 비슷한 역할을 MyInput 컴포넌트가 수행함에도 약간의 다른 사용법을 제공하는 꼴이 됩니다. 이것은 일관적이지 않게 보일 수 있습니다. (input 엘리먼트에 접근하기 위해서 ref가 아닌 inputRef props를 전달해야 함.)

2. forwardRef 사용하기

React에서는 ref를 그대로 전달하는 컴포넌트를 작성할 수 있게 도움을 줍니다. 컴포넌트를 forwardRef로 감싸주면 됩니다.

import { forwardRef, useEffect, useRef } from "react"

const MyInput = forwardRef(function MyInput(props, forwardedRef) {
  useEffect(() => {
    console.log("forwardedRef:", forwardedRef) // forwardedRef: {current: <input type="text">}
  })

  return <input ref={forwardedRef} />
})

function App() {
  const inputRef = useRef()

  useEffect(() => {
    console.log("inputRef:", inputRef) // inputRef: {current: <input type="text">}
    inputRef.current.focus() // MyInput 컴포넌트의 input 엘리먼트에 focus 됨
  })

  return (
    <div>
      <MyInput ref={inputRef} />
    </div>
  )
}

export default App

forwardRef함수에 함수형 컴포넌트 형태의 함수를 매개변수로 넣습니다. 이때, 두 번째 인자로 부모에게서 ref 속성으로 전달된 ref 객체가 전달됩니다.

만약 MyInput 컴포넌트가 클래스 컴포넌트로 작성되었다면, 두 방식을 조합하여 ref를 전달할 수 있습니다.

import { Component, forwardRef, useEffect, useRef } from "react"

class MyInputWithInputRef extends Component {
  componentDidMount() {
    console.log("myInputRef:", this.props.myInputRef) // myInputRef: {current: <input type="text">}
  }

  render() {
    const { myInputRef } = this.props
    return <input ref={myInputRef} />
  }
}

const MyInput = forwardRef(function MyInput(props, forwardedRef) {
  useEffect(() => {
    console.log("forwardedRef:", forwardedRef) // forwardedRef: {current: <input type="text">}
  })

  return <MyInputWithInputRef myInputRef={forwardedRef} />
})

function App() {
  const inputRef = useRef()

  useEffect(() => {
    console.log("inputRef:", inputRef) // inputRef: {current: <input type="text">}
    inputRef.current.focus() // MyInput 컴포넌트의 input 엘리먼트에 focus 됨
  })

  return (
    <div>
      <MyInput ref={inputRef} />
    </div>
  )
}

export default App

코드가 길어졌지만 MyInput을 사용하는 부분(App 컴포넌트)에서는 MyInput 컴포넌트가 어떤식으로 구현되어 있는지 알 필요가 없습니다. <MyInput><input>으로 교체하더라도 아무 문제가 없을 것입니다. 이것은 Input의 역할을 하는 컴포넌트에 대한 일관성과 신뢰성을 부여하게 됩니다.


이제 속성(props)으로서의 ref에 대해 알아봅시다.

React 컴포넌트를 조작하기 위한 ref 전달

ref 속성은 React Element를 참조하기 위한 접근 방법입니다. 이번에는 컴포넌트 수준의 React Element에 접근하는 ref를 다루어 보겠습니다.

클래스 컴포넌트에 ref 전달하기

import { Component, createRef, useEffect, useRef } from "react"

class MyInput extends Component {
  constructor(props) {
    super(props)
    this.state = { value: "" }
    this.inputRef = createRef()
    this.focus = () => {
      this.inputRef.current.focus()
    }
    this.reset = () => {
      this.setState({ value: "" })
    }
  }

  render() {
    return (
      <input
        ref={this.inputRef}
        value={this.state.value}
        onChange={(e) => this.setState({ value: e.target.value })}
      />
    )
  }
}

function App() {
  const ref = useRef()

  const handleReset = () => {
    ref.current.reset() // MyInput 컴포넌트 내부의 state를 조작함
  }

  const handleFocus = () => {
    ref.current.focus() // MyInput 컴포넌트 내부의 input 엘리먼트에 포커스 됨
  }

  useEffect(() => {
    console.log("ref:", ref) // ref: {current: MyInput}
    handleFocus()
  })

  return (
    <div>
      <button onClick={handleReset}>reset</button>
      <button onClick={handleFocus}>focus</button>
      <br />
      <MyInput ref={ref} />
    </div>
  )
}

export default App

클래스로 작성된 컴포넌트에 ref 속성을 전달하면 해당 클래스로부터 생성된 React 엘리먼트에 접근할 수 있습니다. 클래스의 속성이나 메서드에도 접근할 수 있고, state나 context에도 접근할 수 있습니다.(심지어 render 메소드까지)
위의 예시 코드와 같이 하위 컴포넌트의 메서드를 통해 부모 컴포넌트가 자식 컴포넌트를 컨트롤할 수 있습니다.(하지만 이런 데이터, 이벤트 흐름을 리액트에서 권장하지는 않습니다.)

함수 컴포넌트에 ref 전달하기

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

const MyInput = forwardRef(function MyInput(props, forwardedRef) {
  const ref = useRef()
  const [value, setValue] = useState("")

  useEffect(() => {
    // 수동으로 ref 객체에 내부 메서드를 할당
    forwardedRef.current = {
      focus: () => {
        ref.current.focus()
      },
      reset: () => {
        setValue("")
      },
    }
  }, [forwardedRef])

  return (
    <input ref={ref} value={value} onChange={(e) => setValue(e.target.value)} />
  )
})

function App() {
  const ref = useRef()

  const handleReset = () => {
    if (ref.current) {
      ref.current.reset()
    }
  }

  const handleFocus = () => {
    if (ref.current) {
      ref.current.focus()
    }
  }

  useEffect(() => {
    console.log("ref:", ref) // ref: {current: {focus: fn, reset: fn}}
    handleFocus()
  })

  return (
    <div>
      <button onClick={handleReset}>reset</button>
      <button onClick={handleFocus}>focus</button>
      <br />
      <MyInput ref={ref} />
    </div>
  )
}

export default App

함수 컴포넌트는 ref 속성으로 엘리먼트에 접근할 수 없기 때문에, forwardRef를 이용하여 MyInput 컴포넌트 내부로 ref 객체를 전달한 후에 수동으로 내부 동작을 할당해 줄 수 있습니다.

React Hooks 에서는 이와 같은 기능을 제공하는 useImperativeHandle 함수가 있습니다.

import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react"

const MyInput = forwardRef(function MyInput(props, forwardedRef) {
  const ref = useRef()
  const [value, setValue] = useState("")

  // hooks를 이용하여 ref 객체에 내부 메서드를 할당
  useImperativeHandle(forwardedRef, () => ({
    focus: () => ref.current.focus(),
    reset: () => setValue(""),
  }))

  return (
    <input ref={ref} value={value} onChange={(e) => setValue(e.target.value)} />
  )
})

function App() {
  const ref = useRef()

  const handleReset = () => {
    if (ref.current) {
      ref.current.reset()
    }
  }

  const handleFocus = () => {
    if (ref.current) {
      ref.current.focus()
    }
  }

  useEffect(() => {
    console.log("ref:", ref) // ref: {current: {focus: fn, reset: fn}}
    handleFocus()
  })

  return (
    <div>
      <button onClick={handleReset}>reset</button>
      <button onClick={handleFocus}>focus</button>
      <br />
      <MyInput ref={ref} />
    </div>
  )
}

export default App

앞선 예시 코드에서 수동으로 ref 객체에 할당해주던 작업을 useImperativeHandle 함수로 대체하였습니다. useImperativeHandle 함수의 코드를 따라가보면 이렇게 구현되어 있습니다.

function imperativeHandleEffect<T>(
  create: () => T,
  ref: {| current: T | null |} | ((inst: T | null) => mixed) | null | void
) {
  if (typeof ref === "function") {
    const refCallback = ref
    const inst = create()
    refCallback(inst)
    return () => {
      refCallback(null)
    }
  } else if (ref !== null && ref !== undefined) {
    const refObject = ref
    if (__DEV__) {
      if (!refObject.hasOwnProperty("current")) {
        console.error(
          "Expected useImperativeHandle() first argument to either be a " +
            "ref callback or React.createRef() object. Instead received: %s.",
          "an object with keys {" + Object.keys(refObject).join(", ") + "}"
        )
      }
    }
    const inst = create()
    refObject.current = inst
    return () => {
      refObject.current = null
    }
  }
}

Github React 저장소

실제 구현은 조금 더 복잡하지만, 핵심적인 부분은 앞서 수동으로 할당해주던 것과 꽤 비슷하네요.

마무리

DOM 엘리먼트를 참조하기 위해서 주로 ref를 이용합니다. 이번 글에서는 ref 객체가 아닌 React 엘리먼트의 ref 속성이 어떤 동작을 하는지 살펴봤습니다. 리액트를 사용하여 컴포넌트 단위로 UI를 구성하고 코드를 재사용하는 등등 프론트엔드 개발에서 편리한 점이 많은데요. 단순히 사용하는 것을 넘어 리액트가 어떻게 동작하는지 이해하면 더 잘 활용할 수 있을 것 같습니다. 이 글이 리액트의 동작을 이해하는데 도움이 되었기를 바랍니다.

reactfrontend