React 비동기 처리 상태 관리하기

서나무·2023년 5월 4일
2

React

목록 보기
5/5
post-thumbnail

프론트엔드에서는 서버로부터 데이터를 받아올 때, 로딩 에러 성공 등의 상태를 관리해야 합니다.

각 상태에 따라 사용자에게 보여줄 화면이 다르기 때문입니다.

const getUserInfo = () =>
  new Promise((resolve) =>
    setTimeout(
      () => resolve({ name: '서나무', job: '프론트엔드 개발자' }),
      1000,
    ),
  );

여기에 1초 후에 사용자 정보를 반환하는 getUserInfo 함수가 있습니다.

더 정확히 표현하면 위 함수는 Promise를 반환하는데, 반환된 Promise가 1초 후에 resolve 처리를 합니다.

getUserInfo 함수를 통해 받은 데이터를 화면에 보여주는 코드를 작성해볼까요?

import React, { useState, useEffect } from 'react';

const UserProfile = () => {
  const [user, setUser] = useState({});
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUserInfo = async () => {
      setLoading(true); // 로딩 시작
      try {
        setUser(await getUserInfo()); // 상태 업데이트
      } catch (error) {
        setError(error); // 에러 처리
      } finally {
        setLoading(false); // 로딩 종료
      }
    };
    fetchUserInfo();
  }, []);

  if (loading) return <div>로딩중...</div>;
  if (error) return <div>오류가 발생했습니다.</div>;
  return (
    <div>
      <p>이름: {user?.name}</p>
      <p>직업: {user?.job}</p>
    </div>
  );
};

1초 동안은 로딩중...이라는 문구가 보여지고, 1초 후에 사용자 정보를 화면에 출력합니다.

만약 비동기 함수 호출에 따른 상태 관리를 한 번만 한다면 위의 코드를 사용해도 괜찮다고 생각합니다.

하지만 서버로부터 데이터를 받아와야 하는 경우가 많아진다면, 비동기 처리 상태를 관리하는 로직을 추상화하는 것이 필요해집니다.

1. Hook으로 분리하기

비동기 처리를 하는 함수를 파라미터로 받아옵니다.

그리고 기존에 getUserInfo를 대체하기만 하면 끝입니다!

const useFetch = (fetchCallback) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  const fetchData = async () => {
    setLoading(true);
    try {
      setData(await fetchCallback());
    } catch (error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return {
    loading,
    error,
    data,
    refetch: fetchData,
  };
};

사용하는 방법은 매우 간단하며,

const UserProfile = () => {
  const { data, error, loading } = useFetch(getUserInfo);
  if (loading) return <div>로딩중...</div>;
  if (error) return <div>오류가 발생했습니다.</div>;
  return (
    <div>
      <p>이름: {data?.name}</p>
      <p>직업: {data?.job}</p>
    </div>
  );
};

사용자 정보가 아닌, 게시글 정보, 댓글 정보 등 다양한 비동기 처리 시 상태를 관리하기 쉬워졌습니다.

2. useReducer로 상태 업데이트 로직 분리하기

현재 useFetch hook에서 개선해야 할 점들이 보입니다.

  • 여러 개의 useState를 사용
  • 상태를 업데이트하는 로직이 종속되어 있음

useReducer는 여러 개의 상태를 관리할 수 있으며, action이라는 객체로 상태를 업데이트 하는 로직을 분리합니다.

const useFetchReducer = (state, action) => {
  switch (action.type) {
    case 'LOADING': // loading 중
      return {
        loading: true,
        error: null,
        data: null,
      };
    case 'ERROR': // 비동기 처리 reject(실패)
      return {
        loading: false,
        error: action.error,
        data: null,
      };
    case 'SUCCESS': // 비동기 처리 resolve(성공)
      return {
        loading: false,
        error: null,
        data: action.data,
      };
    default:
      throw Error(`[useFetch] ${action.type} is not valid`);
  }
};

상태 변화를 조작하는 reducer 함수입니다.

action의 type을 받아서, type에 따라 상태를 반환합니다.

const useFetch = (fetchCallback) => {
  const [state, dispatch] = useReducer(
    useFetchReducer, // 상태 조작 함수
    { // 상태 초기화
      loading: false,
      error: null,
      data: null,
    }
  );

  const fetchData = async () => {
    dispatch({ type: 'LOADING' });
    try {
      dispatch({ type: 'SUCCESS', data: await fetchCallback() });
    } catch (error) {
      dispatch({ type: 'ERROR', error });
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return {
    ...state,
    refetch: fetchData,
  };
};

setState를 사용하지 않고, dispatch를 사용해 상태를 조작합니다.

이 외에는 기존의 useFetch와 크게 다른점이 없으며, 사용법도 동일합니다.

만약 hook으로 분리하지 않았다면 비동기 처리를 하는 모든 로직을 찾아 수정했을텐데, 하나의 hook으로 관리하니 유지보수도 용이해졌습니다.

3. TypeScript로 마이그레이션

JavaScript는 타입이 없는 언어입니다.

타입이 없다는 것은 유연하다고 볼 수도 있고, 안정성이 보장되지 않는다고 볼 수 있다고 생각합니다.

TypeScript를 사용해 타입을 설정하면, 런타임 오류를 최소화하고 코드의 안정성을 향상시킬 수 있습니다.

import React, { useReducer, useEffect } from 'react';

// State와 Action에서 공통으로 사용
interface BaseState<D, E> {
  error?: E | null;
  data?: D | null;
}

// State 타입
interface UseFetchState<D, E> extends BaseState<D, E> {
  loading: boolean;
}

// Action 타입
interface UseFetchAction<D, E> extends BaseState<D, E> {
  type: 'LOADING' | 'ERROR' | 'SUCCESS';
}

const useFetchReducer = <D, E>(
  state: UseFetchState<D, E>,
  action: UseFetchAction<D, E>,
): UseFetchState<D, E> => {
  switch (action.type) {
    case 'LOADING':
      return {
        loading: true,
        error: null,
        data: null,
      };
    case 'ERROR':
      return {
        loading: false,
        error: action.error,
        data: null,
      };
    case 'SUCCESS':
      return {
        loading: false,
        error: null,
        data: action.data,
      };
    default:
      throw Error(`[useFetch] ${action.type} is not valid`);
  }
};

// 제네릭으로 반환 데이터와 오류의 타입 지정
const useFetch = <D, E>(fetchCallback: () => Promise<D>) => {
  const [state, dispatch] = useReducer(useFetchReducer<D, E>, {
    loading: false,
    error: null,
    data: null,
  } as UseFetchState<D, E>);

  const fetchData = async () => {
    dispatch({ type: 'LOADING' });
    try {
      dispatch({ type: 'SUCCESS', data: await fetchCallback() });
    } catch (error) {
      dispatch({ type: 'ERROR', error });
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return {
    ...state,
    refetch: fetchData,
  };
};

reducer 함수의 state, action의 타입을 지정해주는 것과 제네릭으로 데이터와 오류 타입을 추상화했습니다.

interface UserInfo {
  name: string;
  job: string;
}

const getUserInfo = () =>
  //❗Promise 반환 타입 정의
  new Promise<UserInfo>((resolve, reject) =>
    setTimeout(
      () => resolve({ name: '서나무', job: '프론트엔드 개발자' }),
      1000,
    ),
  );

Promise가 반환하는 데이터 타입을 UserInfo로 설정해줍니다. 타입을 지정해주지 않으면 unknown으로 추론되기 때문입니다.

기존에는 함수의 반환 타입을 지정해줬는데, 댓글로 new Promise<T>으로 타입 지정해주는 방법을 알려주셔서 수정했습니다. 감사합니다.

profile
주니어 프론트엔드 개발자

4개의 댓글

comment-user-thumbnail
2023년 5월 10일

원하는 내용이 있어 참고하겠습니다. 좋은 글 감사합니다.

마지막에서, 비동기 함수의 반환 타입을 정의하는 부분을 new Promise로 변경하면 굳이 함수의 리턴타입을 명시하지 않아도 됩니다 :)

1개의 답글