커스텀 Hook

고성인·2025년 3월 15일

React

목록 보기
10/17

Hook이란?

Hook은 자바스크립트 함수로 정의되지만 호출 위치에 제약이 있는 특별한 유형의 재사용 가능한 UI로직으로, React에서 use로 시작하는 함수를 의미한다.
지금까지 사용해왔던 useState, useRef, useEffect등이 모두 React의 hook이다.

Hook의 규칙

React에서 hook은 2가지 특별한 규칙이 존재한다.

  1. hook은 항상 최상위 레벨에서 호출해야하며, 반복문, 조건문, 중첩 함수, try/catch/finally블록 등에서 호출해서는 안된다.
  2. hook은 React의 함수 컴포넌트 혹은 커스텀 hook에서만 호출해야한다.

커스텀 Hook

React에는 useState, useRef등과 같이 미리 만들어져있는 hook들이 존재하지만 필요성에 따라 hook을 직접 만드는 것도 가능하다.
이러한 커스텀 hook은 어떤 상황에서 사용하는 것일까?
우리가 함수를 만들어 사용하는 이유는 재사용성에 있다.
중복되는 코드를 제거하고, 재사용 가능한 코드를 작성하기 위해 함수를 사용하는것처럼 hook역시 같은 로직을 재사용할 때 사용된다.

import { useEffect, useState } from "react";
import Places from "./Places.jsx";
import ErrorPage from "./ErrorPage.js";
import { sortPlacesByDistance } from "../loc.js";
import { fetchAvailablePlaces } from "../http.js";

interface AvailablePlacesProps {
  onSelectPlace: (place: PlaceType) => void;
}

export default function AvailablePlaces({ onSelectPlace }: AvailablePlacesProps) {
  const [availablePlaces, setAvailablePlaces] = useState<PlaceType[]>([]);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState<Error>();

  useEffect(() => {
    (async () => {
      setIsFetching(true);
      try {
        const places = await fetchAvailablePlaces();

        navigator.geolocation.getCurrentPosition((position) => {
          const sortedPlaces = sortPlacesByDistance(
            places,
            position.coords.latitude,
            position.coords.longitude
          );

          setAvailablePlaces(sortedPlaces);
          setIsFetching(false);
        });
      } catch (error) {
        if (error instanceof Error) {
          setError({
            ...error,
            message: error.message || "Could not fetch places, please try again later."
          });
        }
        setIsFetching(false);
      }
    })();
  }, []);

  if (error) {
    return <ErrorPage title="An error occurred!" message={error.message} />;
  }

  return (
    <Places
      title="Available Places"
      places={availablePlaces}
      isLoading={isFetching}
      loadingText="Fetching place data..."
      fallbackText="No places available."
      onSelectPlace={onSelectPlace}
    />
  );
}

AvailablePlaces.tsx

import { useRef, useState, useCallback, useEffect } from "react";
import Modal from "./components/Modal";
import DeleteConfirmation from "./components/DeleteConfirmation";
import logoImg from "./assets/logo.png";
import Places from "./components/Places";
import AvailablePlaces from "./components/AvailablePlaces";
import { fetchUserPlaces, updateUserPlaces } from "./http";
import ErrorPage from "./components/ErrorPage";

function App() {
  const selectedPlace = useRef<PlaceType | null>(null);

  const [userPlaces, setUserPlaces] = useState<PlaceType[]>([]);
  const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState<Error | null>(null);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState<Error>();

  const [modalIsOpen, setModalIsOpen] = useState(false);

  useEffect(() => {
    (async () => {
      setIsFetching(true);
      try {
        const places = await fetchUserPlaces();
        setUserPlaces(places);
        setIsFetching(false);
      } catch (error) {
        if (error instanceof Error) {
          setError({
            ...error,
            message: error.message || "Could not fetch places, please try again later."
          });
        }
        setIsFetching(false);
      }
    })();
  }, []);

  function handleStartRemovePlace(place: PlaceType) {
    setModalIsOpen(true);
    selectedPlace.current = place;
  }

  function handleStopRemovePlace() {
    setModalIsOpen(false);
  }

  async function handleSelectPlace(selectedPlace: PlaceType) {
    setUserPlaces((prevPickedPlaces) => {
      if (!prevPickedPlaces) {
        prevPickedPlaces = [];
      }
      if (prevPickedPlaces.some((place) => place.id === selectedPlace.id)) {
        return prevPickedPlaces;
      }
      return [selectedPlace, ...prevPickedPlaces];
    });

    try {
      await updateUserPlaces([selectedPlace, ...userPlaces]);
    } catch (error) {
      if (error instanceof Error) {
        setUserPlaces(userPlaces);
        setErrorUpdatingPlaces({ ...error, message: error.message || "Failed to update places" });
      }
    }
  }

  const handleRemovePlace = useCallback(
    async function handleRemovePlace() {
      setUserPlaces((prevPickedPlaces) =>
        prevPickedPlaces.filter((place) => place.id !== selectedPlace.current?.id)
      );

      try {
        await updateUserPlaces(
          userPlaces.filter((place) => place.id !== selectedPlace.current?.id)
        );
      } catch (error) {
        if (error instanceof Error) {
          setUserPlaces(userPlaces);
          setErrorUpdatingPlaces({ ...error, message: error.message || "Failed to delete places" });
        }
      }

      setModalIsOpen(false);
    },
    [userPlaces]
  );

  const handleError = () => {
    setErrorUpdatingPlaces(null);
  };

  return (
    <>
      <Modal open={!!errorUpdatingPlaces} onClose={handleError}>
        {errorUpdatingPlaces && (
          <ErrorPage
            title="An error occurred!"
            message={errorUpdatingPlaces.message}
            onConfirm={handleError}
          />
        )}
      </Modal>
      <Modal open={modalIsOpen} onClose={handleStopRemovePlace}>
        <DeleteConfirmation onCancel={handleStopRemovePlace} onConfirm={handleRemovePlace} />
      </Modal>

      <header>
        <img src={logoImg} alt="Stylized globe" />
        <h1>PlacePicker</h1>
        <p>
          Create your personal collection of places you would like to visit or you have visited.
        </p>
      </header>
      <main>
        {error && <ErrorPage title="An error occurred!" message={error.message} />}
        {!error && (
          <Places
            title="I'd like to visit ..."
            fallbackText="Select the places you would like to visit below."
            places={userPlaces}
            isLoading={isFetching}
            loadingText="Fetching your places..."
            onSelectPlace={handleStartRemovePlace}
          />
        )}

        <AvailablePlaces onSelectPlace={handleSelectPlace} />
      </main>
    </>
  );
}

export default App;

App.tsx

위 두 파일의 코드는 모두 data를 fetch해오는 기능이 담겨져 있고, 로직이 매우 비슷한 것을 알 수 있다.
위 두 파일에서 해당 부분만을 뽑아내보면 다음과 같다.

const [userPlaces, setUserPlaces] = useState<PlaceType[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<Error>();

useEffect(() => {
  (async () => {
    setIsFetching(true);
    try {
      const places = await fetchUserPlaces();
      setUserPlaces(places);
      setIsFetching(false);
    } catch (error) {
      if (error instanceof Error) {
        setError({
          ...error,
          message: error.message || "Could not fetch places, please try again later."
        });
      }
      setIsFetching(false);
    }
  })();
}, []);

//App.tsx

const [availablePlaces, setAvailablePlaces] = useState<PlaceType[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<Error>();
useEffect(() => {
  (async () => {
    setIsFetching(true);
    try {
      const places = await fetchAvailablePlaces();
      navigator.geolocation.getCurrentPosition((position) => {
        const sortedPlaces = sortPlacesByDistance(
          places,
          position.coords.latitude,
          position.coords.longitude
        );
        setAvailablePlaces(sortedPlaces);
        setIsFetching(false);
      });
    } catch (error) {
      if (error instanceof Error) {
        setError({
          ...error,
          message: error.message || "Could not fetch places, please try again later."
        });
      }
      setIsFetching(false);
    }
  })();
}, []);

//AvailablePlaces.tsx

두 코드 모두 data를 저장하는 상태와 isFetching, error를 useState를 사용한 상태로 갖고있으며, useEffect를 통해 데이터를 호출하여 각각의 상태를 변화시키는 동일한 구조로 이루어져있다.

이러한 중복되는 부분을 일반적인 javascript에서는 함수로 분리하여 사용할 수 있지만, React에서 hook을 사용한 경우에는 커스텀 hook을 만들어 분리할 수 있다.

커스텀 hook 만들기

우선 다른 hook들과 동일하게 커스텀 hook역시 이름의 맨 앞부분이 use로 시작해야 한다.
왜냐하면 React는 use로 시작하는 함수에게는 이 함수가 커스텀 hook이라는 규칙을 부여하기 때문이다.
위에서 설명한 hook의 규칙에서 hook은 React의 함수 컴포넌트 혹은 커스텀 hook에서만 호출해야한다.라는 규칙에 따라 use로 시작하는 이름의 함수에서 hook의 사용이 가능해진다.

그 외의 부분은 일반적인 함수를 만드는 과정과 비슷하며, 위의 데이터를 fetch해오는 부분은 다음과 같이 useFetch라는 커스텀 hook을 만들 수 있다.

import { useEffect, useState } from "react";

export function useFetch<T>(fetchFn: () => Promise<T>, initialValue: T) {
  const [fetchedData, setFetchedData] = useState<T>(initialValue);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState<Error>();

  useEffect(() => {
    (async () => {
      setIsFetching(true);
      try {
        const data = await fetchFn();
        setFetchedData(data);
        setIsFetching(false);
      } catch (error) {
        if (error instanceof Error) {
          setError({
            ...error,
            message: error.message || "Could not fetch data, please try again later."
          });
        }
        setIsFetching(false);
      }
    })();
  }, [fetchFn]);

  return {
    fetchedData,
    setFetchedData,
    isFetching,
    error
  };
}

커스텀 hook역시 함수이기때문에 반환값을 가질 수 있으며, 이 반환값에는 useState로 만들어진 상태등이 들어갈 수 있다.

0개의 댓글