본문 바로가기

FrontEnd/React

[React] useRef와 useImperativeHandle 훅 이해하기

ref 속성이란?

 모든 리액트 컴포넌트는 reference의 앞 3글자를 따 ref 속성을 제공한다.  ref 속성은 초기에는 null 값 이였다가 마운트 되는 시점에 물리DOM객체의 값으로 변경된다.  즉 ref는 물리 DOM 객체의 참조이다.

 

 HTML 요소는 자바스크립트에선 DOM 타입 객체이며 <div>, <button> 같은 요소는 HTMLElement 상속타입인 HTMLDivElement, HTMLButtonElement 타입 객체이다. 


 HTMLElement 타입은 click(), blur(), focus() 메서드를 제공하며 리액트 요소가 물리 DOM 상태일때만 호출할 수 있다. 

 

 ref 속성값은 물리 DOM 상태일 때의 값으로 ref로 얻은 값(DOM 객체)을 사용해 click()과 같은 이벤트를 호출할 수 있다.

다음 ref속성의 정의 코드에서 타입이 Ref<T>인걸 알 수 있고 여기서 타입 변수 T는 HTMLElement와 같은 DOM타입이다.

 

 Ref<T>는 current라는 읽기 속성을 가진 RefObject<T> 타입이며 값이 null일 수 있다.  왜냐하면 리액트 요소가 마운트 되기 전인 가상 DOM타입일 때는 값이 null이기 때문이다.  또한 current는 리액트 내부에서 설정하는 값이므로 사용자가 임의로 변경해선 안되므로 리액트에서 값을 읽기전용(readonly)으로 설정했다.

// ref 속성 정의
interface RefAttributes<T> extends Attributes {
    ref?: Ref<T> | undefined;
}

// RefObject<T> 타입 정의
interface RefObject<T> {
    readonly current: T | null;
}
type Ref<T> = RefObject<T> | null;

 

 

useRef 훅 알아보기

 react 패키지에서는 useRef 훅 제공하며 useRef의 반환값 타입은 MutaleRefObject<T>이다.  MutableRefObject<T> 타입은 ref 속성의 타입인 RefObject<T> 처럼 current라는 속성을 가지고 있다.  

// react패키지에서 제공하는 useRef 훅 임포트
import {useRef} from 'react'

// useRef 훅의 반환값 타입
function useRef<T>(initialValue: T): MutableRefObject<T>;

// MutableRefObject<T> 타입 정의
interface MutableRefObject<T> {
    current: T;
}

 

 

 useRef훅을 사용하기 위한 코드를 추가한다.

import {useRef, useCallback} from 'react'
import {Title} from '../components'

export default function ClickTest() {
  const inputRef = useRef<HTMLInputElement>(null)
  // ?. 널 병합 연산자 사용 -> 마운트 이전에는 current의 값은 null이기 때문이다.
  const onClick = useCallback(() => inputRef.current?.click(), [])  

  return (
    <section className="mt-4">
      <Title>ClickTest</Title>
      <div className="mt-4 flex justify-center items-center">
        <button className="btn btn-primary mr-4" onClick={onClick}>
          Click Me
        </button>
        <input ref={inputRef} className="hidden" type="file" accept="image/*" />
      </div>
    </section>
  )
}

 

실행결과로 버튼으로 callback을 실행해 input의 ref속성을 통해 click()함수를 호출한다.

 

 

 FileList 클래스와 Array.from() 함수

  FileList는 File 타입의 리스트이며 자바스크립트 배열이 아닌 유사 배열 객체(array-like objects)이다.  자바스크립트에서는 유사 배열 객체를 Array.from() 함수를 사용해 배열로 변환할 수 있다.

const fileArray: File[] = Array.from(files)  // 배열로 변환 처리

 

 

 FileReader 클래스로 File 타입 객체 읽기

  자바스크립트 엔진은 File 타입 객체를 읽을 수 있도록 FileReader라는 클래스를 기본으로 제공한다. FileReader 클래스가 제공하는 readAsDataURL() 메서드는 File 타입 객체를 읽어 문자열로 된 이미지를 Base64 인코딩으로 제공한다.

 

  다만 File 타입 객체에 담긴 데이터는 바이너리 데이터이므로 base64 인코딩 시 시간이 걸리므로 FileReader 클래스에서는 onload 이벤트 속성을 제공해 인코딩 된 이후 작업이 처리되도록 도움을 준다.

const file : File
const fileReader = new FileReader
fileReader.onload = (e: ProgressEvent<FileReader>) => {
    if (e.target) {
        const result = e.target.result
    }
}
filereader.readAsDataURL(file)

 

 imageFileReaderP 유틸리티 함수 만들기

  imageFileReader의 패턴은 매번 개발하기 번거로우기 때문에 유틸리티 함수를 만들기 위해 코드를 추가한다.

export const imageFileREaderP = (file: Blob) =>
  new Promise<string>((resolve, reject) => {
    const fileReader = new FileReader()
    fileReader.onload = (e: ProgressEvent<FileReader>) => {
      const result = e.target?.result

      if (result && typeof result === 'string') resolve(result)
      else reject(new Error(`imageFileReaderP: can't read image file`))
    }
    fileReader.readAsDataURL(file)
  })

 

 FileDrop 컴포넌트 만들기

  imageFileReaderP 유틸리티 함수를 사용하는 FileDrop컴포넌트 코드를 추가한다. 
 onInputChange, onDivDrop의 useCallback 훅 makeImageUrls가 의존성 배열에 포함되지 않으면, makeImageUrls가 변경되었을 때 해당 변경 사항이 반영되지 않고 이전 버전의 makeImageUrls를 계속 사용하게 되므로 추가한 것이다.

 

  하단의 소스에서 map(imageFileREaderP) 과 map((file) => imageFileREaderP(file))는 같은방식으로 동작하며 이유는 자바스크립트의 함수는 일급 객체이기 때문에 함수 이름을 그대로 전달 시 자동으로 배열의 요소를 함수의 인자로 전달한다.

import type {ChangeEvent, DragEvent} from 'react'
import {useState, useRef, useCallback, useMemo} from 'react'
import {useToggle} from '../hook'
import {Title, Div} from '../components'
import {imageFileREaderP} from '../utils'

export default function FileDrop() {
  const [imageUrls, setImageUrls] = useState<string[]>([])
  const [error, setError] = useState<Error | null>(null)
  const [loading, toggleLoading] = useToggle(false)

  const inputRef = useRef<HTMLInputElement>(null)
  const onDivClick = useCallback(() => inputRef.current?.click(), [])

  // File배열을 imageFileREaderP Util을 통해 base64 인코딩 후
  // 추가된 urls 배열을 기존 imageUrls배열에 병합한다.
  const makeImageUrls = useCallback(
    (files: File[]) => {
      const promises = Array.from(files).map(imageFileREaderP)
      toggleLoading()
      Promise.all(promises)
        .then(urls => setImageUrls(imageUrls => [...urls, ...imageUrls]))
        .catch(setError)
        .finally(toggleLoading)
    },
    [toggleLoading]
  )

  // input의 onchange이벤트를 통해 makeImageUrls(Array.from(files)) 호출
  const onInputChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      setError(null)
      const files = e.target.files
      files && makeImageUrls(Array.from(files))
    },
    [makeImageUrls]
  )

  const ondivDragOver = useCallback((e: DragEvent) => e.preventDefault(), [])
  const onDivDrop = useCallback( // 드롭한 파일이 있을경우 makeImageUrls(Array.from(files)) 호출
    (e: DragEvent) => {
      e.preventDefault()
      setError(null)
      const files = e.dataTransfer?.files
      files && makeImageUrls(Array.from(files))
    },
    [makeImageUrls]
  )

  // prettier-ignore
  // imagesUrls로 jsx생성
  const images = useMemo(() => {
    return imageUrls.map((url,index) => (
      <Div key={index} src={url}
        className='m-2 bg-transparent bg-center bg-no-repeat bg-contain' 
        width='5rem' height='5rem' />
    ))
  },[imageUrls])

  // prettier-ignore
  return (
    <section className="mt-4">
      <Title>FileDrop</Title>
      {error && (
        <div className='p-4 mt-4 bg-red-200'>
          <p className='text-3xl text-red-500 text-bold'>{error.message}</p>
        </div>
      )}

      <div onClick={onDivClick} className='w-full mt-4 bg-gray-200 border border-gray-500'>
        {loading && (
          <div className='flex items-center justify-center'>
            <button className='btn btn-circle loading'></button>
          </div>
        )}
        
        <div onDragOver={ondivDragOver} onDrop={onDivDrop} className='flex flex-col items-center justify-center h-40 cursor-pointer'>
          <p className='text-3xl font-bold'>drop images or click me</p>
        </div>
        <input ref={inputRef} onChange={onInputChange} multiple className='hidden' type='file' accept='images/*' />
      </div>

      <div className='flex flex-wrap justify-center'>{images}</div>
    </section>
  )
}

 

실행결과

 

 

 <input> 요소의 ref 속성 사용하기

  <input>의 ref 속성의 inputRef.current값이 물리 DOM 객체이므로 foucs() 메서드를 호출한 코드를 추가한다.

 

 

import {useRef, useEffect} from 'react'
import {Title} from '../components'

export default function InputFocusTest() {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => inputRef.current?.focus(), [])
  // prettier-ignore
  return (
    <section className="mt-4">
      <Title>InputFocusTest</Title>
      <div className="flex justify-center mt-4">
        <input ref={inputRef} className='input input-primary' placeholder='enter som text' />
      </div>
    </section>
  )
}

 

실행결과 : 화면에 최초 진입 시 useEffect()를 통해 input에 포커스(선택)가 된 것을 확인할 수 있다.

 

 

 useState 호출 없이 <input>의 value 속성값 얻기

  리액트의 <input>의 value 속성 코드 패턴의 작성은 매번 작성하기엔 번거로운 부분이 있다.  이런 패턴을 요구하는 이유는 가상 DOM환경에서 빠른 리렌더링을 위해서이다.

// <input>의 value 속성 코드 패턴
const [value, setValue] = useState<string>('')
const onChangeValue = (e: ChangeEvent<HTMLInputElement>) => setValue(notUsed => e.target.value)

<input value={value} onChange={onChangeValue}

 

 

  하지만 ref 속성이 유효한 값, 즉 물리DOM 객체가 만들어지면 value속성 값을 통해 값을 얻을 수 있으므로 useState훅을 사용하지 않고서 input value 속성값을 가져올 수 있다.  ref를 통한 value값을 얻어오는 코드를 추가하자.

import {useRef, useEffect, useCallback} from 'react'
import {Title} from '../components'

export default function InputValueTest() {
  const inputRef = useRef<HTMLInputElement>(null)

  const getValue = useCallback(() => alert(`input value:${inputRef.current?.value}`), [])

  useEffect(() => inputRef.current?.focus(), [])
  // prettier-ignore
  return (
    <section className="mt-4">
      <Title>InputValueTest</Title>
      <div className="flex justify-center mt-4">
        <div className='flex flex-col w-1/3 p-2'>
          <input ref={inputRef} className='input input-primary' />
          <button onClick={getValue} className='mt-4 btn btn-primary'>get value</button>
        </div>
      </div>
    </section>
  )
}

 

실행결과 : useState를 사용하지 않고도 useRef 훅을 통해 value를 얻어온 결과를 확인할 있다.

 

 

forwardRef 함수 이해하기

 forwardRef 함수는 부모 컴포넌트에서 생성한  ref를 자식 컴포넌트로 전달해 주는 역할을 한다.  

 

 forwardRef 함수가 필요한 이유 알기

  예를들어 사용자 컴포넌트를 통해 <input> 요소를 사용하는 Input을 만들었다고 가정해보자.  이럴경우 <input>은 ref 속성값을 사용할 수 있지만 Input은 ref 속성값을 사용할 수 없다.

import {Input} from '../theme/daisyui'

<Input ref={inputRef} className="input-primary" />

 

 

  왜냐하면 Input은 사용자 컴포넌트이므로 물리  DOM 객체를 얻을 수 없기 때문이다.  해당 내용을 확인할 수 있는 코드를 추가하자.  정상적으로 값을 ref를 통해 전달받기 위해서는 forwardRef를 사용해야 한다.

import {useRef, useEffect, useCallback} from 'react'
import {Title} from '../components'
import {Input} from '../theme/daisyui'

export default function ForwardRefTest() {
  const inputRef = useRef<HTMLInputElement>(null)

  const getValue = useCallback(() => alert(`input value:${inputRef.current?.value}`), [])

  useEffect(() => inputRef.current?.focus(), [])
  // prettier-ignore
  return (
    <section className="mt-4">
      <Title>ForwardRefTest</Title>
      <div className="flex justify-center mt-4">
        <div className='flex flex-col w-1/3 p-2'>
          <Input ref={inputRef} className='input input-primary' />
          <button onClick={getValue} className='mt-4 btn btn-primary'>get value</button>
        </div>
      </div>
    </section>
  )
}

 

실행결과 : ref를 통해 value값을 얻어오지 못하는 것을 확인할 수 있다.

 

 

 forwardRef 함수의 타입

  아래의 타입정보 중 타입 변수 T(input의 타입인 HTMLInputElement)는 ref 대상 컴포넌트의 타입이고 P는 컴포넌트의 속성 타입입니다.  앞서 본 사용자 컴포넌트인 Input 컴포넌트의 속성 타입은 InputProps이므로 forwardRef 타입정보에서 타입변수 P는 InputProps이다.

// forwardRef 타입 정보
function forwardRef<T, P = {}> (render: ForwardRefRenderfunction<T, P>): 반환_타입;

// Input의 속성타입
export type ReactInputProps = DetailedHTMLProps<
    InputHTMLAttributes<HTMLInputElement>,HTMLInputElement>

export type InputProps = ReactInputProps & {}

 

  이를 참고해 ref 관련 부분을 forwardRef 함수로 전달하는 형태로 완성된 Input.tsx 컴포넌트 파일을 변경하자.

import {type DetailedHTMLProps, type InputHTMLAttributes} from 'react'
import {forwardRef} from 'react'

export type ReactInputProps = DetailedHTMLProps<
  InputHTMLAttributes<HTMLInputElement>,
  HTMLInputElement
>

export type InputProps = ReactInputProps & {}

export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  const {className: _className, ...inputProps} = props
  const className = ['input', _className].join(' ')
  return <input ref={ref} {...inputProps} className={className} />
})

 

실행결과 : forwardRef 함수를 사용해 input의 ref 속성을 사용자컴포넌트까지 전달해 사용할 수 있게 처리했다.

 

 

useImperativeHandle 훅이란?

 useImperativeHandle 훅은 컴포넌트 기능을 JSX가 아닌 타입스크립트 코드에서 사용한다.  useImperativeHandle 훅의 탄생 배경에 대해 살펴보자.

 

이 코드가 성립하는건 textInput 코어 컴포넌트에 focus메서드를 제공하기 때문이다.  근데 생각을 달리해 아래와 같이 TextInputMethod 타입의 객체를 useRef<TextInput | null> 대신 useRef<TextInputMethods | null>로 사용하고자 하는 것에서 시작 되었다.

const textInputRef = useRef<TextInput | null>(null)
const setFocus = () => textInputRef.current?.focus()

// 이 타입을 useRef<TextInputMethods | null>로 사용하는건 어떨까?
export type TextInputMethods = {  
    focus: () => void
    dismiss: () => void
}

 

 

 useImperativeHandle 훅의 타입

 useImperativeHandle 훅의 타입 정의이다.  매개변수 Ref는 forwardRef 호출로 얻는 값을 입력하는 용도이고, init은 useMemo 훅과 유사하게 '() => 메서드_객체' 형태의 함수를 입력하는 용도이다.

function useImperativeHandle<T, R extends T>(ref: Ref<T> | undefined, init: () => R, detps?:
DependencyList): void;

// useMemo 훅과 useImperativeHandle 훅 비교
const object = useMemo(() => ({}), [])
const handle = useImperativeHandle(ref, () => ({}), [])

 

  useImperativeHandle의 매개변수 ref는 forwardRef 함수를 호출해 얻은 ref를 사용해야 한다.  즉 Input 과 같이 커스텀 태그의 컴포넌트 내부에서만 사용이 가능함을 의미한다.

 

 

 ValidatableInput 컴포넌트 만들기

import type {ReactInputProps} from './Input'
import {forwardRef, useImperativeHandle, useMemo, useRef} from 'react'

export type ValidatableInputMethods = {
  validate: () => [boolean, string]
}

export const ValidatableInput = forwardRef<ValidatableInputMethods, ReactInputProps>(
  ({type, className: _className, ...inputProps}, methodsRef) => {
    const className = useMemo(() => ['input', _className].join(' '), [_className])
    
    // input 객체의 ref를 얻어온다.
    const inputRef = useRef<HTMLInputElement>(null)

    useImperativeHandle(
      methodsRef,
      () => ({
        validate: (): [boolean, string] => {
          const value = inputRef.current?.value
          if (!value || !value.length) return [false, '사용자가 입력한 낸용이 없습니다.']
          switch (type) {
            case 'email': {
              const regEx =
                /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i
              const valid = regEx.test(value)
              
              // 값이 참이면 value를 반환, false이면 '틀린 이메일 주소입니다' string을 반환
              return valid ? [true, value] : [false, '틀린 이메일 주소입니다.']
            }
          }
          return [true, value]
        }
      }),
      [type]
    )
    return <input {...inputProps} className={className} ref={inputRef} />
  }
)

 

 

import {useRef, useCallback} from 'react'
import {Title} from '../components'
import type {ValidatableInputMethods} from '../theme/daisyui'
import {ValidatableInput} from '../theme/daisyui'

export default function ValidatableInputTest() {
  const methodsRef = useRef<ValidatableInputMethods>(null)

  const validateEmail = useCallback(() => {
    if (methodsRef.current) {
      const [valid, valueOrErrorMessage] = methodsRef.current.validate()
      if (valid) alert(`${valueOrErrorMessage}는 유효한 이메일 주소입니다.`)
      else alert(valueOrErrorMessage)
    }
  }, [])
  // prettier-ignore
  return (
    <section className="mt-4">
      <Title>ValidatableInputTest</Title>
      <div className="flex justify-center mt-4">
        <div className='flex flex-col w-1/3 p-2'>
        <ValidatableInput type='email' ref={methodsRef} className='input-primary' />
        <button onClick={validateEmail} className='mt-4 btn btn-primary'>validate</button>
        </div>
      </div>
    </section>
  )
}