All'alba vincerò

At dawn, I will win!

React

[React] 커스텀 훅(Custom Hook): 컴포넌트에서 재사용할 수 있는 로직을 함수로 정의

나디아 Nadia 2024. 8. 22. 00:38

📌 커스텀 훅(Custom Hook)

: 여러 컴포넌트에서 재사용할 수 있는 로직을 추출해 하나의 함수로 정의한 것

  • 코드의 재사용성을 높이고, 코드의 가독성을 개선할 수 있다.

 

 

☑️ 빌트인 훅 함수

: 리액트는 useState(), useEffect(), useRef() 등 빌트인 훅 함수를 제공한다.

  • 빌트인 훅 함수만 가지고는 모든 것을 처리할 수 없기 때문에 커스텀 훅을 만들어 사용한다.
    • useState()
    • useRef()
    • useEffect()
    • useDebugValue()
    • useMemo()
    • useSyncExternalStore()
    • useCallback()
    • useLayoutEffect()
    • useImperativeHandle()
    • useId()

 

 

 

⏩  커스텀 훅(Custom Hook)이 필요한 경우

  1. 반복되는 로직이 있을 때
    ➡︎ 컴포넌트 간 로직 공유
  2. 상태와 사이드 이펙트를 분리하고 싶을 때
  3. 가독성과 유지보수성을 높이고 싶을 때

 

 

 

커스텀 훅(Custom Hook) 최적화하기

  • 커스텀 훅 함수를 작성할 경우, 리렌더링 이슈 방지와 효과적인 렌더링 관리를 위해 반환하는 모든 함수를 useCallback() 훅으로 감싸는 것이 좋다.

 

 

 

✅ 커스텀 훅(Custom Hook) 사용 규칙

 

1️⃣ Hook의 이름은 항상 use로 시작한다.

  1. React 컴포넌트의 이름은 항상 대문자로 시작해한다.
    - JSX처럼 어떻게 보이는지 React가 알 수 있는 무언가를 반환해야 한다.
    (ex. StatusBar, SaveButton)
  2. Hook의 이름은 use 뒤에 대문자로 시작한다.
    (ex. useState (내장된 Hook), useOnlineStatus (커스텀 Hook))
  • ex) getColor()라는 함수 ➡︎ 함수명이 use로 시작하지 않으므로 함수 안에 상태(state)가 없다는 것을 확신할 수 있다.
    useOnlineStatus() 함수 ➡︎ 내부에 다른 Hook을 사용하고 있을 확률이 높다.

 

 

2️⃣ 커스텀 Hook은 state 그 자체를 공유하는게 아니라 state 저장 로직을 공유한다. 

  • 커스텀 Hook은 같은 Hook을 호출하더라도 각각의 Hook 호출은 완전히 독립되어 있어야 한다.
  • 커스텀 Hook을 추출하기 전과 후가 동일해야(순수해야)한다!
    ➡︎ 리액트 컴포넌트가 재렌더링될 때마다 그 컴포넌트 내에서 호출된 모든 훅은 다시 실행되기 때문에
    훅은 동일한 입력이 주어졌을 때 항상 동일한 출력을 반환해야 한다. 

  • 커스텀 Hook은 컴포넌트에서 반복되는 로직을 추출하여 만든다.
    1. state가 존재한다.
      (ex.  firstName와 lastName)
    2. 변화를 다루는 함수가 존재한다.
      (ex. handleFirstNameChange와 handleLastNameChange)
    3. 해당 입력에 대한 로직의 속성을 지정하는 JSX가 존재한다.
      (ex.  value와 onChange)

 

 

3️⃣ Hook 사이에 상호작용하는 값 전달하기 

  • 리액트의 훅 간에 상태나 값을 공유하고, 항상 최신의 props와 state를 전달받아야 올바르게 동작한다.
    (ex. useState로 관리하는 상태를 useEffect에서 사용하는 경우)

  • 훅(Hook)은 컴포넌트가 재렌더링될 때마다 다시 호출되므로,
    항상 최신의 props와 state를 기반으로 동작해야 한다.

    (ex. 서버에서 데이터를 가져오는 useFetch(커스텀 훅)
    이 훅은 컴포넌트에서 전달받은 URL을 이용해 데이터를 가져온다.

    이때 컴포넌트가 재렌더링되어 URL이 변경되면, useFetch 훅은 새로운 URL을 사용하여 데이터를 다시 가져온다.)
import React, { useState, useEffect } from 'react';

function MessageComponent() {

  // 상태를 관리하는 훅
  const [count, setCount] = useState(0);


  // 상태가 변경될 때마다 콘솔에 메시지를 출력하는 훅
  useEffect(() => {
    console.log(`You have clicked the button ${count} times`);
  }, [count]); // 이펙트는 최신 count 값을 의존성으로 받음


  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default MessageComponent;

 

 


 

☑️ 문서 제목 변경

  • useDocumentTitle() 훅
import { useLayoutEffect } from 'react';

/** @type { (documentTitle: string) => void } */
function useDocumentTitle(documentTitle) {
  useLayoutEffect(() => {
    document.title =
      documentTitle + ' - ' + import.meta.env.VITE_DOCUMENT_TITLE;
  }, [documentTitle]);
}

export default useDocumentTitle;


// 사용 ---------------------
const documentTitle = '시계 ON/OFF ← 이펙트 동기화 & 정리';

useDocumentTitle(documentTitle);

 

 

 

☑️ 이벤트 연결 (자동 해지)

  • useEventListener() 훅
import { useEffect } from 'react';

/** @type { (target: Document | HTMLElement, eventType: string, eventHandler: (e: Event) => void) => void} */
function useEventListener(target, eventType, eventHandler) {
  useEffect(() => {
    target.addEventListener(eventType, eventHandler);

    return () => {
      target.removeEventListener(eventType, eventHandler);
    };
  }, [target, eventType, eventHandler]);
}

export default useEventListener;


// 사용 --------------------
const onMouseTracking = useCallback(({ pageX: x, pageY: y }) => {
  setMousePosition({ x, y });
}, []);
// 종속성 배열이 비어 있기 때문에 이 함수 참조는 리-렌더링 과정에서 항상 동일하다.

useEventListener(document, 'mousemove', onMouseTracking);

 

 


☑️ 마우스 위치 추적

  • useMousePosition() 훅
import { useCallback, useState } from 'react';
import useEventListener from './useEventListener';

function useMousePosition() {
  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });

  const onMouseTracking = useCallback(({ pageX: x, pageY: y }) => {
    setMousePosition({ x, y });
  }, []);

  useEventListener(document, 'mousemove', onMouseTracking);

  return mousePosition; // { x, y }
}

export default useMousePosition;


// 사용 ---------------
import useDocumentTitle from '@/hooks/useDocumentTitle';
import S from './PrintMousePosition.module.css';
import useMousePosition from '@/hooks/useMousePosition';

function PrintMousePosition() {
  useDocumentTitle('마우스 위치 추적 ← 이펙트 동기화 & 정리');
  const { x, y } = useMousePosition();

  return (
    <div className={S.component}>
      <output>
        {x} <span>/</span> {y}
      </output>
    </div>
  );
}

export default PrintMousePosition;

 

 

 

☑️ 온/오프라인 상태

  • useOnline() 훅
    • useState(), useCallback() 훅으로 구현
    • useSyncExternalStore() 훅으로 구현
import {
  useCallback,
  useDebugValue,
  useState,
  useSyncExternalStore,
} from 'react';
import useEventListener from './useEventListener';

function useOnline() {

  // isOnline 상태 선언
  const [isOnline, setIsOnline] = useState(() => navigator.onLine);

  // 리-렌더링 되더라도 처음에 기억한 함수를 동일 참조
  // 불필요한 리-렌더를 차단 (성능 최적화)
  const onOnline = useCallback(() => setIsOnline(true), []);
  const onOffline = useCallback(() => setIsOnline(false), []);

  // 커스텀 훅
  // 자동 이벤트 연결 및 해지
  useEventListener(globalThis, 'online', onOnline);
  useEventListener(globalThis, 'offline', onOffline);

  // 개발도구에서 표시되는 레이블 지정
  useDebugValue(isOnline, () => (isOnline ? '온라인' : '오프라인'));

  return isOnline;
}


// 사용 ----------------
import useOnline from '@/hooks/useOnline';
import Switcher from '../sync-web-storage/components/Switcher';

function CheckOnOffline() {
  const isOnline = useOnline();

  return (
    <div style={{ display: 'flex', flexFlow: 'column', gap: 20 }}>
      <h1>Check On/Offline</h1>
      <Switcher value={isOnline} />
    </div>
  );
}

export default CheckOnOffline;

 

 

 

☑️ 시계 정보 표시

  • useClock() 훅
import { useDebugValue, useEffect, useState } from 'react';

function useClock() {
  // 타임 상태 선언
  const [time, setTime] = useState(new Date());

  // 타임 업데이트 이펙트
  useEffect(() => {
    const clearId = setInterval(() => {
      const nextTime = new Date();
      setTime(nextTime);
    }, 1000);

    return () => {
      clearInterval(clearId);
    };
  }, []);

  // 리액트 개발 도구 포멧 표시 설정
  useDebugValue(time, () => time.toLocaleTimeString());

  // 상태 값 반환
  return time;
}

export default useClock;


// 사용 ---------------
import { bool, func } from 'prop-types';
import S from './ClockOnOff.module.css';
import useDocumentTitle from '@/hooks/useDocumentTitle';
import useClock from '@/hooks/useClock';

ClockOnOff.propTypes = {
  isOn: bool,
  onToggle: func,
};

function ClockOnOff({ isOn = false, onToggle }) {
  useDocumentTitle('시계 ON/OFF ← 이펙트 동기화 & 정리');
  const time = useClock();

  const buttonLabel = isOn ? 'OFF' : 'ON';

  return (
    <div className={S.component}>
      <button type="button" lang="en" onClick={onToggle}>
        CLOCK {buttonLabel}
      </button>
      <output hidden={!isOn}>{time.toLocaleTimeString()}</output>
    </div>
  );
}

export default ClockOnOff;

 

 

 

☑️ 상태 변경 & 콜백

  • useStateWithCallbak() 훅
import { useEffect, useState } from 'react';

/** @type {(initialValue: any, callback?: (nextState: any) => void) => [state, setState]} */
function useStateWithCallback(initialValue, callback) {

  // 상태 선언
  const [state, setState] = useState(initialValue);

  // 상태를 추적하는 이펙트 추가
  useEffect(() => {
    callback?.(state);
    // callback이 변경된다고 해서 이펙트 함수가 다시 실행되어서는 안된다.
  }, [state]);

  // 반환 값
  return [state, setState];
}

export default useStateWithCallback;


// 사용 -----------------
function ChangeStateAndCallback() {
  const [count, setCount] = useStateWithCallback(0);

  const [message, setMessage] = useStateWithCallback(
    /* 초기 상태 */
    'hello',
    /* 상태 업데이트 이후 실행이 보장되는 콜백 함수 (이펙트 코드 추가 가능) */
    (nextMessage) => {
      pRef.current.textContent = nextMessage.toUpperCase();
      setCount((c) => c + 1);
    }
  );

  const handleChangeMessage = () => {
    setMessage((m) => `${m} ❤️`);
  };

  const pRef = useRef(null);


  return (
    <>
      <button type="button" style={buttonStyles} onClick={handleChangeMessage}>
        메시지 변경 ({count})
      </button>

      {/* 선언적 프로그래밍 */}
      <output style={outputStyles}>{message.toUpperCase()}</output>
      <p ref={pRef} style={pStyles} />
    </>
  );
}

const buttonStyles = { alignSelf: 'start' };
const outputStyles = { fontWeight: 800 };
const pStyles = {
  border: '3px solid #d9dedf',
  borderRadius: 4,
  margin: 0,
  padding: 6,
};

 

 

 

☑️ 상태 토글

  • useToggle() 훅
import useStateWithCallback from './useStateWithCallback';

// 1. 직접 구현
/** @type {(initialValue?: boolean, callback?: (nextState) => void) => [isToggle, setIsToggle]} */
function _useToggle(initialValue = false, callback) {

  const [isToggle, setIsToggle] = useState(initialValue);

  useEffect(() => {
     callback?.(isToggle);
  }, [isToggle]);

  return [isToggle, setIsToggle];
}

// 2. useStateWithCallback() 커스텀 훅 활용
/** @type {(initialValue?: boolean, callback?: (nextState) => void) => [isToggle, setIsToggle]} */
function useToggle(initialValue = false, callback) {
  return useStateWithCallback(initialValue, callback);
}

export default useToggle;


// 사용 ---------------------
function CheckOnOffline() {
  // const isOnline = useOnline();
  const [isToggle, setIsToggle] = useToggle(false, (nextIsToggle) => {
    document.body.style.backgroundColor = nextIsToggle ? '#2f2f2f' : 'white';
  });

  return (
    <div style={{ display: 'flex', flexFlow: 'column', gap: 20 }}>
      <h1>Check On/Offline</h1>
      <Switcher value={isToggle} onToggle={() => setIsToggle(t => !t)} />
      <p>{ isToggle.toString() }</p>

      <ChangeStateAndCallback />
    </div>
  );
}

export default CheckOnOffline;

 

 

 

☑️ 뷰포트 진입/진출

  • useInView() 훅
import { useLayoutEffect, useRef, useState } from 'react';

/** @type{ (printLog?: boolean) => { inView, targetRef, rootRef } } */
function useInView(printLog = false) {

  const [inView, setInView] = useState(false);
  // inView 상태 -> 뷰포트 안에 관찰 대상이 들어왔는 지(참) / 들어오지 않았는 지(거짓) 반환
  
  const targetRef = useRef(null);
  // targetRef 참조 -> 문서에 존재하고 뷰포트 내부에 진입/진출 여부를 관찰할 문서 요소 참조
  
  const rootRef = useRef(document);
  // rootRef 참조 -> 문서에 존재하고 뷰포트로서 설정할 루트 요소 참조

  useLayoutEffect(() => {
    printLog && console.log('인터섹션 옵저버 생성, 관찰 대상 관찰하도록 설정');
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;

      if (entry.isIntersecting) {
        printLog && console.log('뷰포트(rootRef.current) 안에 진입');
        setInView(true);
      } else {
        printLog && console.log('뷰포트(rootRef.current) 밖으로 진출');
        setInView(false);
      }
    });

    const { current: targetElement } = targetRef;

    // targetRef.current 존재할 경우
    if (targetElement) {
      // 대상 요소 관찰 설정
      observer.observe(targetElement);
    } else {
      console.warn('문서에 관찰할 대상 요소가 존재하지 않습니다.');
    }

    return () => {
      printLog &&
        console.log('인터섹션 옵저버에 의해 관찰 중인 대상의 관찰을 중지 설정');
      targetElement && observer.unobserve(targetElement);
    };
  }, [printLog]);

  return { inView, targetRef, rootRef };
}

export default useInView;

 

 

 

 

☑️ 웹 스토리지 읽기/쓰기

  • useSessionStorage() 훅
  • useLocalStorage() 훅
import useStateWithCallback from './useStateWithCallback';

const { localStorage, sessionStorage } = globalThis;

// 웹 스토리지 데이터 읽기
const getStorageItem = (key, storageType = 'local') => {
  const storage = storageType.includes('local') ? localStorage : sessionStorage;
  const item = storage.getItem(key);
  const data = JSON.parse(item);
  return data ?? null;
};

// 웹 스토리지 데이터 쓰기
const setStorageItem = (key, value, storageType = 'local') => {
  const storage = storageType.includes('local') ? localStorage : sessionStorage;
  const stringifyValue = JSON.stringify(value);
  storage.setItem(key, stringifyValue);
};

// 웹 스토리지 데이터 삭제
const deleteStorageItem = (key, storageType = 'local') => {
  const storage = storageType.includes('local') ? localStorage : sessionStorage;
  if (!key) console.warn('삭제할 아이템의 키가 존재하지 않습니다.');
  else storage.removeItem(key);
};

// 웹 스토리지 데이터 모두 삭제
const allClearItems = (storageType = 'local') => {
  const storage = storageType.includes('local') ? localStorage : sessionStorage;
  storage.clear();
};


// 로컬 스토리지
export function useLocalStorage(key, initialValue, autoSave = false) {
  const [state, setState] = useStateWithCallback(
    () => getStorageItem(key) ?? initialValue,
    (nextState) => autoSave && setItem(nextState)
  );

  const getItem = () => getStorageItem(key);
  const setItem = (newValue) => setStorageItem(key, newValue);
  const deleteItem = () => deleteStorageItem(key);
  const allClear = () => allClearItems();

  return [
    state,
    setState,
    /* methods */ {
      getItem,
      setItem,
      deleteItem,
      allClear,
    },
  ];
}

// 세션 스토리지
export function useSessionStorage(key, initialValue, autoSave = false) {
  const [state, setState] = useStateWithCallback(
    () => getStorageItem(key) ?? initialValue,
    (nextState) => autoSave && setItem(nextState)
  );

  const getItem = () => getStorageItem(key, 'session');
  const setItem = (newValue) => setStorageItem(key, newValue, 'session');
  const deleteItem = () => deleteStorageItem(key, 'session');
  const allClear = () => allClearItems('session');

  return [
    state,
    setState,
    {
      getItem,
      setItem,
      deleteItem,
      allClear,
    },
  ];
}

export default {
  useLocalStorage,
  useSessionStorage,
};

 

 

 

☑️ 네트워크 요청/응답

  • useFetch() 훅
// [목적]
// 리액트 렌더링 프로세스(동기)와 관련 없는 사이드 이펙트(비동기) 처리
// 네트워크 요청/응답 처리하기 위한 상태
// - 상황(status) 상태
// - 오류(error) 유무 상태
// - 데이터(data) 유무 상태

// [기능]
// - 비동기 요청/응답
// - 응답 상황에 따른 상태 반환
// - 개발 중(StrictMode) 2회 렌더링 되는 문제(순수성 검사) 해결 (중복 요청 취소)

// [사용법]
// const { status, error, data } = useFetch(url);
// if (status === 'pending' | 'loading' | 'success' | 'error') { ... }
// if (error || status === 'error') { ... }
// if (status === 'success' || data) { ... }

// ============================================

// import { useState } from "react";
import { useEffect } from 'react';
import { useImmer } from 'use-immer';

/** @type {(url: string) => { status: 'pending' | 'loading' | 'success' | 'error', error: null | Error, data }} */
function useFetch(url) {
  // 1. 상태 선언
  const [state, setState] = useImmer({
    status: 'pending',
    error: null,
    data: null,
  });


  // 2. 이펙트 처리
  useEffect(() => {
    // 요청을 취소할 수 있고, 신호를 제공할 수 있는 객체 생성
    const abortController = new AbortController();

    // 상태 업데이트 (대기 -> 로딩 중...)
    setState((draft) => {
      draft.status = 'loading';
    });

    // 이펙트 내부의 비동기 함수
    const fetchData = async () => {
      try {
        // 요청 / 응답
        const response = await fetch(url, {
          signal: abortController.signal,
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
          },
        });

        // 응답이 실패한 경우
        // catch(error) 블록으로 응답
        if (!response.ok) {
          throw new Response(
            { message: '서버 응답이 실패했습니다.' },
            { status: 500 }
          );
        }

        // 응답 결과에서 JSON 데이터로 변환
        const responseData = await response.json();

        // 응답이 성공한 경우
        // 상태 업데이트 (로딩 중... -> 성공)
        setState((draft) => {
          draft.status = 'success';
          draft.data = responseData;
        });
        
      } catch (error) {
        if (!(error instanceof DOMException)) {
          // 응답이 실패한 경우
          // 상태 업데이트 (로딩 중... -> 실패)
          setState((draft) => {
            draft.status = 'error';
            draft.error = error;
          });
        }
      }
    };

    // 이펙트 함수 실행
    fetchData();

    // 정리
    return () => {
      // 중복 요청인 경우, 요청 취소
      abortController.abort();
    };
  }, [url, setState]);


  // 3. 반환 값
  // { status, error, data }
  return state;
}

export default useFetch;

 

 


 

 

커스텀 Hook으로 로직 재사용하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

useCallback – React

The library for web and native user interfaces

ko.react.dev