본문 바로가기

프로젝트/세컨핸드

[기능 구현] 커스텀 훅(Custom Hook)을 사용하여 비동기 로딩 및 에러 상태 처리

- 프로젝트를 진행하면서 React 커스텀 훅에 대해서 알게 되었고, 팀 동료 스눕과 함께 프로젝트에서 사용할 커스텀 훅을 만들어보기로 했다.

 

React 커스텀 훅이란?

- React 커스텀 훅이란, React의 기본 훅(Hook)을 조합하여 사용자 정의 로직을 재사용하기 위한 매커니즘이다.

- 즉, 자신만의 커스텀 훅을 만들면 컴포넌트 로직을 함수로 뽑아내어 재사용할 수 있으며, 컴포넌트에서 사용하고 싶은 값들을 반환해주면 된다.

- 커스텀 훅은 use 라는 키워드로 시작하는 파일을 만들고 그 안에 함수를 작성한다.

- 커스텀 훅의 장점은 재사용성(비슷한 로직을 여러 컴포넌트에서 반복적으로 사용해야 할 때, 커스텀 훅을 만들어 코드를 재사용할 수 있음)과 복잡한 컴포넌트 로직을 외부로 분리하여 관리할 수 있어, 코드의 가독성과 유지 보수성이 향상된다는 점이다.

- 다음은 커스텀 훅의 간단한 예제이다.

// NameForm.jsx
import { useState } from "react";

export const NameForm = () => {
  const [name, setName] = useState("");

  const handleNameChange = (e) => {
    setName(e.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={name}
        onChange={handleNameChange}
        placeholder="Enter your name"
      />
    </div>
  );
};

// EmailForm.jsx
import { useState } from "react";

export const EmailForm = () => {
  const [email, setEmail] = useState("");

  const handleEmailChange = (e) => {
    setEmail(e.target.value);
  };

  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={handleEmailChange}
        placeholder="Enter your email"
      />
    </div>
  );
};

- 위 예제 코드에서 NameForm 컴포넌트와 EmailForm 컴포넌트는 각각 사용자로부터 이름(name)과 이메일(email) 정보를 입력받는 폼 컴포넌트이다. 이들 컴포넌트는 입력 받은 값을 각각의 컴포넌트에서 state로 관리하고 사용자가 입력 필드에 데이터를 입력하면, 이벤트 핸들러 함수가 호출되어 해당 컴포넌트의 state가 업데이트된다.

- 두 개의 컴포넌트는 사용자로부터 입력 받은 값을 각각의 state로 관리하고 사용자가 입력 필드에 값을 입력할 때마다 이벤트 핸들러 함수가 호출되어 해당 컴포넌트의 상태를 업데이트하는 비슷하게 반복되는 패턴을 보이고 있는데, 반복되는 로직을 커스텀 훅으로 추출할 수 있다.

// useFormInput 커스텀 훅

import { useState } from "react";

// initialValue 매개변수는 폼 입력 필드의 초기값을 설정하는 데 사용
export const useFormInput = (initialValue) => {
// useState 훅을 사용하여 value라는 상태 변수와 이 상태를 업데이트하는 함수 setValue를 선언
  const [value, setValue] = useState(initialValue);

  // handleChange 함수는 입력 필드의 값이 변경될 때 호출된다.
  // 이벤트 객체 e를 매개변수로 받으며,입력 필드의 최신값(e.target.value)을
  // setValue 함수를 사용하여 value 상태에 저장
  const handleChange = (e) => {
    setValue(e.target.value);
  };
  
  // value와 handleChange 함수를 포함하는 객체를 반환
  return {
    value,
    onChange: handleChange,
  };
};
// NameForm.jsx
import { useFormInput } from "./useFormInput";

export const NameForm = () => {
  // useFormInput 커스텀 훅을 호출하여, 값을 입력받는 폼 필드의 상태를 관
  // 입력 필드의 초기값으로 빈 문자열("")을 전달
  const name = useFormInput("");
  return (
    <div>
    // input 요소에 ({...name}) 스프레드 연산자로 적용
    // useFormInput에서 관리하는 value 상태와 onChange 이벤트 핸들러를 input 필드 속성으로 전달
    // 이를 통해 입력 필드의 상태 관리와 이벤트 처리가 가능
      <input type="text" {...name} placeholder="Enter your name" />
    </div>
  );
};

// EmailForm.jsx
import { useFormInput } from "./useFormInput";

export const EmailForm = () => {
  const email = useFormInput("");

  return (
    <div>
      <input type="email" {...email} placeholder="Enter your email" />
    </div>
  );
};

- useFormInput 커스텀 훅을 적용하여 동일한 로직을 추출하여 각 컴포넌트를 간결하게 만들고, 코드의 유지 보수성 향상 시킬 수 있다.

 

프로젝트에서 사용한 커스텀 훅

- 우리 팀은 프로젝트에서 반복적으로 사용되는 비동기 통신과 상태를 관리하는 커스텀 훅을 만들었다.

- 다음은 실제 프로젝트에서 사용한 커스텀 훅 코드이다.

 

- ActionType으로 LOADING, SUCCESS, ERROR 세 가지 상태의 타입을 정의한다.

export enum ActionType {
  LOADING = 'loading',
  SUCCESS = 'success',
  ERROR = 'error',
}

 

- Action은 ActionType과 선택적으로 데이터 혹은 에러를 포함한 payload를 갖는다.

- State interface는 로딩 상태, 데이터, 에러를 포함한다.

 

 

type Action<D> = {
  type: ActionType;
  payload?: { data: D | null; error: Error | null };
};

interface State<D> {
  loading: boolean;
  data: D | null;
  error: Error | null;
}

 

- reducer 함수는 현재 상태와 액션을 받아서 새로운 상태를 반환한다.

const reducer = <D>(state: State<D>, action: Action<D>): State<D> => {
  switch (action.type) {
    case ActionType.LOADING:
      return { loading: true, data: null, error: null };
    case ActionType.SUCCESS:
      return { loading: false, data: action.payload ? action.payload.data : null, error: null };
    case ActionType.ERROR:
      return { loading: false, data: null, error: action.payload ? action.payload.error : null };
    default:
      return state;
  }
};

 

- Response 타입은 데이터와 에러를 포함할 수 있다.

- Callback 타입은 비동기 함수의 형태로, Response 타입을 반환하는 Promise이다.

export type Response<D> = { data?: D; error?: Error };

type Callback<D> = () => Promise<Response<D>>;

 

- 다음은 useFecth 함수 코드이다.

- initialState로 초기 상태를 정의한다.

- useReducer를 통해 상태 관리에 이전에 정의한 reducer 함수를 사용한다.

- fetchData는 데이터를 가져오는 비동기 함수로, callback 함수를 호출하고 상태를 업데이트한다.

- useEffect로 deps 배열(의존성 배열)의 값이 변경될 때마다 fetchData 함수를 호출한다. 만약 initialFetch가 true이면 초기 렌더링 시에도 fetchDtat를 호출한다.

- 반환 값은 현재 비동기 처리의 상태(state)와 데이터를 다시 가져오는 함수(fetchData)이다.

 

 

export const useFetch = <D, E>(callback: Callback<D>, deps: E[], initialFetch = false) => {
  const initialState: State<D> = { loading: false, data: null, error: null };
  const [state, dispatch] = useReducer(reducer, initialState);

  const fetchData = async () => {
    dispatch({ type: ActionType.LOADING });
    try {
      const res = await callback();
      if (!res.data) {
        return dispatch({
          type: ActionType.ERROR,
          payload: { data: null, error: res.error ?? null },
        });
      }
      return dispatch({
        type: ActionType.SUCCESS,
        payload: { data: res.data, error: null },
      });
    } catch (error) {
      if (error instanceof Error) {
        dispatch({
          type: ActionType.ERROR,
          payload: { data: null, error },
        });
      }
    }
  };

  useEffect(() => {
    initialFetch && fetchData();
  }, deps);

  return [state, fetchData] as [State<D>, () => Promise<void>];
};

 

 

- useFetch 전체 코드

import { useEffect, useReducer } from 'react';

export enum ActionType {
  LOADING = 'loading',
  SUCCESS = 'success',
  ERROR = 'error',
}

type Action<D> = {
  type: ActionType;
  payload?: { data: D | null; error: Error | null };
};

interface State<D> {
  loading: boolean;
  data: D | null;
  error: Error | null;
}

const reducer = <D>(state: State<D>, action: Action<D>): State<D> => {
  switch (action.type) {
    case ActionType.LOADING:
      return {
        loading: true,
        data: null,
        error: null,
      };
    case ActionType.SUCCESS:
      return {
        loading: false,
        data: action.payload ? action.payload.data : null,
        error: null,
      };
    case ActionType.ERROR:
      return {
        loading: false,
        data: null,
        error: action.payload ? action.payload.error : null,
      };
    default:
      return state;
  }
};

export type Response<D> = { data?: D; error?: Error };

type Callback<D> = () => Promise<Response<D>>;

export const useFetch = <D, E>(
  callback: Callback<D>,
  deps: E[],
  initialFetch = false
) => {
  const initialState: State<D> = {
    loading: false,
    data: null,
    error: null,
  };
  const [state, dispatch] = useReducer(reducer, initialState);

  const fetchData = async () => {
    dispatch({ type: ActionType.LOADING });
    try {
      const res = await callback();
      if (!res.data)
        return dispatch({
          type: ActionType.ERROR,
          payload: { data: null, error: res.error ?? null },
        });
      return dispatch({
        type: ActionType.SUCCESS,
        payload: { data: res.data, error: null },
      });
    } catch (error) {
      if (error instanceof Error)
        dispatch({
          type: ActionType.ERROR,
          payload: { data: null, error },
        });
    }
  };

  useEffect(() => {
    initialFetch && fetchData();
  }, deps);

  return [state, fetchData] as [State<D>, () => Promise<void>];
};

 

- useFetch를 실제로 사용하는 코드

// ...
  const [{ data, error, loading }, contentsFetch] = useFetch(
    getFavoriteItemsAPI.bind(null, categoryIdx),
    []
  );
  
  useEffect(() => {
    contentsFetch();
  }, [categoryIdx]);

  const renderComps = () => {
    if (userInfo?.isLoggedIn === false) {
      return <Navigate to="/profile" replace />;
    }
    if (loading) {
      return <Loading />;
    }
    if (error || !data || !data.items) {
      return <Error>{error ? error.message : ERROR_MESSAGE.UNDEFINED}</Error>;
    }

    if (data.items.length === 0) {
      return <Error>관심 상품으로 등록된 상품이 없어요.</Error>;
    }
  // ...
  }

- useFetch가 반환하는 data, error, loading 상태를 통해 각 상태에 따른 컴포넌트를 렌더링을 하고, useEffect를 통해 categoryIdx가 변경될 때마다 contentsFetch 함수를 호출하여 데이터를 업데이트 하고 있다.

 

배운 점

- 비동기 로딩 및 에러 상태 처리를 위한 커스텀 훅을 팀 동료와 함께 페어 프로그래밍을 통해 구현했는데, 많은 것을 배울 수 있었고, 커스텀 훅을 만들면서 처음에는 오히려 개발 속도가 저하되는 것 같았지만, 이후에 일관성 있는 로직과 생산성이 향상되면서 커스텀 훅의 장점을 알게되었다.


참고 : https://react-ko.dev/learn/reusing-logic-with-custom-hooks

https://ko.legacy.reactjs.org/docs/hooks-custom.html

https://react.vlpt.us/basic/21-custom-hook.html