useReducer + useContext에서의 비동기 작업에 대해 알아보자

불꽃남자·2021년 6월 9일
0

지난 시간엔 useReducer + useContext를 이용한 Redux 패턴 구현에 대해 알아보았다.
이번 포스트에선 그에 더해 API를 요청하고, 요청의 성공과 실패 여부에 따라 상태를 어떻게 바꿀 것인지에 대해 알아볼 것이다.

코드 예제는 velopert님의 API 연동 강의를 사용했다.

useReducer + useContext + 비동기 작업

예제 코드에서는 JSON placeholder에서 제공하는 API를 사용한다.

이전 포스팅에선 보기 쉬우라고 비즈니스 로직을 App 컴포넌트에 다 몰아넣었는데, 이번엔 비즈니스 로직이 좀 길어질 예정이기 때문에 userContext.tsx 파일으로 분리했다.

/// userContext.tsx
interface UserT {
  id:       number;
  name:     string;
  username: string;
  email:    string;
  address:  Address;
  phone:    string;
  website:  string;
  company:  Company;
}
interface Address {
  street:  string;
  suite:   string;
  city:    string;
  zipcode: string;
  geo:     Geo;
}
interface Geo {
  lat: string;
  lng: string;
}
interface Company {
  name:        string;
  catchPhrase: string;
  bs:          string;
}

interface UsersStateT {
  users: {
    loading: boolean,
    error: null | AxiosError,
    data: null | UserT[]
  },
  user: {
    loading: boolean,
    error: null | AxiosError,
    data: null | UserT
  }
}

type UsersActionT =
 | { type: 'GET_USERS' }
 | { type: 'GET_USERS_SUCCESS', data: UserT[] }
 | { type: 'GET_USERS_FAILURE', error: AxiosError }
 | { type: 'GET_USER' }
 | { type: 'GET_USER_SUCCESS', data: UserT }
 | { type: 'GET_USER_FAILURE', error: AxiosError };

타입스크립트를 쓰고 있기 때문에 먼저 타입부터 선언한다.
API 요청으로 받아올 user 데이터의 타입을 선언하고, reducer로 관리할 상태의 타입과 액션의 타입을 선언한다.

const initialState = {
  users: {
    loading: false,
    error: null,
    data: null
  },
  user: {
    loading: false,
    error: null,
    data: null
  }
};

const usersReducer = (state: UsersStateT, action: UsersActionT): UsersStateT => {
  switch (action.type) {
    case 'GET_USERS':
      return {
        ...state,
        users: {
          ...state.users,
          loading: true
        }
      };
    case 'GET_USERS_SUCCESS':
      return {
        ...state,
        users: {
          ...state.users,
          data: action.data,
          loading: false
        }
      };
    case 'GET_USERS_FAILURE':
      return {
        ...state,
        users: {
          ...state.users,
          error: action.error,
          loading: false
        }
      };
    case 'GET_USER':
      return {
        ...state,
        user: {
          ...state.user,
          loading: true
        }
      };
    case 'GET_USER_SUCCESS':
      return {
        ...state,
        user: {
          ...state.user,
          data: action.data,
          loading: false
        }
      };
    case 'GET_USER_FAILURE':
      return {
        ...state,
        user: {
          ...state.user,
          error: action.error,
          loading: false
        }
      };
    default:
      throw new Error("invalid action type");
  }
}

const UsersStateContext = createContext<null | UsersStateT>(null);
const UsersDispatchContext = createContext<null | Dispatch<UsersActionT>>(null);

export const UsersProvider = ({ children }: { children: React.ReactNode }) => {
  const [usersState, usersDispatch] = useReducer(usersReducer, initialState);

  return (
    <UsersStateContext.Provider value={usersState}>
      <UsersDispatchContext.Provider value={usersDispatch}>
        {children}
      </UsersDispatchContext.Provider>
    </UsersStateContext.Provider>
  );
};

useReducer의 인자로 넘길 initialState와 reducer를 선언한다.
StateContext와 dispatchContext를 createContext로 생성한다.
그리고 UsersProvider라는 함수를 선언한다. UsersProvider는 ReactNode를 인자로 받아와서 UsersStateContext와 UsersDispatchContext의 Provider로 감싸서 반환하는 함수다. 이러면 나중에 Provider로 컴포넌트를 감쌀 때에

<UsersProvider>
  <SomeComponent />
</UsersProvider>

이렇게만 해주면 되어서 편리하다.

export const useUsersState = () => {
  const usersState = useContext(UsersStateContext);
  if (!usersState) throw new Error("Can not find userStateProvider");
  return usersState;
}

export const useUsersDispatch = () => {
  const usersDispatch = useContext(UsersDispatchContext);
  if (!usersDispatch) throw new Error("Can not find userDispatchProvider");
  return usersDispatch;
}

이 두 함수는 useContext를 편하게 사용하기 위한 custom hook이다. state와 dispatch의 초기값이 null이기 때문에 useContext로 값을 불러왔을 때 null일 수도 있는 가능성을 가지고 있다. if문으로 null check를 해준 다음 context 값을 반환한다.

export const getUsers = async (dispatch: React.Dispatch<UsersActionT>) => {
  dispatch({ type: 'GET_USERS' });
  try {
    const response = await axios.get<UserT[]>('https://jsonplaceholder.typicode.com/users');
    dispatch({ type: 'GET_USERS_SUCCESS', data: response.data });
  } catch(e) {
    dispatch({ type: 'GET_USERS_FAILURE', error: e });
  }
}

export const getUser = async (dispatch: React.Dispatch<UsersActionT>, id: number) => {
  dispatch({ type: 'GET_USER' });
  try {
    const response = await axios.get<UserT>(`https://jsonplaceholder.typicode.com/users/${id}`);
    dispatch({ type: 'GET_USER_SUCCESS', data: response.data });
  } catch(e) {
    dispatch({ type: 'GET_USER_FAILURE', error: e });
  }
}

API를 요청하는 함수를 선언한다. 이 함수는 인자로 dispatch함수를 받는다.

함수를 호출하면 GET 액션을 dispatch한다. 그럼 state의 loading이 true로 바뀐다.

try문 안에서 axios의 get함수로 API를 요청한다. 정상적으로 응답을 받으면 GET_SUCCESS 액션을 응답 데이터와 함께 dispatch한다. 그럼 state의 loading은 false로 바뀌고 data에 응답 데이터가 저장된다.

만일 요청이 정상적으로 이루어지지 않으면 GET_FAILURE 액션을 에러 내용과 함께 dispatch한다. 그럼 state의 loading은 false로 바뀌고 error에 에러 내용이 저장된다.

이것으로 끝이다! 리팩터링의 여지가 여기저기 남아있지만 상기의 코드는 우리가 의도했던 바를 모두 충족시켜주는 코드이다.

이제 이 비즈니스 로직을 컴포넌트에서 사용해보자.

// App.tsx

import { UsersProvider } from "./contexts/UsersContext";
import Users from "./components/Users";

const App = () => {
  return (
      <UsersProvider>
        <Users />
      </UsersProvider>
  );
};

export default App;

최상위 컴포넌트 App에서 하위 컴포넌트를 UsersProvider로 감싸준다. 이제 하위 컴포넌트에서 UsersContext의 state와 dispatch를 사용할 수 있게 된다.

// Users.tsx

import { useState } from 'react';
import { useUsersState, useUsersDispatch, getUsers } from '../contexts/UsersContext';
import User from './User';

function Users() {
  const [userId, setUserId] = useState<null | number>(null);
  const state = useUsersState();
  const dispatch = useUsersDispatch();

  const { data: users, loading, error } = state.users;
  const fetchData = () => {
    getUsers(dispatch);
  };

  if (loading) return <div>로딩중..</div>;
  if (error) return <div>에러가 발생했습니다</div>;
  if (!users) return <button onClick={fetchData}>불러오기</button>;

  return (
    <>
      <ul>
        {users.map(user => (
          <li
            key={user.id}
            onClick={() => setUserId(user.id)}
            style={{ cursor: 'pointer' }}
          >
            {user.username} ({user.name})
          </li>
        ))}
      </ul>
      <button onClick={fetchData}>다시 불러오기</button>
      {userId && <User id={userId} />}
    </>
  );
}

export default Users;

stateContext와 dispatchContext의 값을 불러온다.
usersState.users의 data, error, loading을 구조 분해한다.
fetchData 함수를 선언한다. fetchData 함수를 호출하면 getUsers 함수를 실행해서 users API를 요청한다.

loading, error state가 각각 활성화되어 있다면 그에 맞는 UI를 화면에 표시한다.
users state가 없다면 불러오기 버튼을 화면에 표시한다. 불러오기 버튼을 누르면 fetchData 함수를 실행한다.

위의 조건에 모두 해당하지 않으면 users state를 map으로 순회하며 li요소를 반환한다. li요소는 해당 user의 username이 담겨있고, 클릭하면 해당 user의 id를 userId state에 저장한다.
userId state가 있으면 userId를 props로 User 컴포넌트를 호출해 화면에 표시한다.

// User.tsx

import { useEffect } from 'react';
import { useUsersState, useUsersDispatch, getUser } from '../contexts/UsersContext';

function User({ id }: { id: number }) {
  const state = useUsersState();
  const dispatch = useUsersDispatch();
  useEffect(() => {
    getUser(dispatch, id);
  }, [dispatch, id]);

  const { data: user, loading, error } = state.user;

  if (loading) return <div>로딩중..</div>;
  if (error) return <div>에러가 발생했습니다</div>;
  if (!user) return null;
  return (
    <div>
      <h2>{user.username}</h2>
      <p>
        <b>Email:</b> {user.email}
      </p>
    </div>
  );
}

export default User;

User 컴포넌트도 Users 컴포넌트와 구조가 동일하다.
usersState와 usersDispatch를 참조하고, dispatch와 id에 의존성을 가지는 useEffect로 getUser 함수를 호출하고, state.user.data가 존재하면 컴포넌트의 내용을 화면에 표시한다.

id는 Users에서 받아오니까, Users 컴포넌트의 user중 하나를 클릭하면 해당 user의 username과 email이 화면에 표시되는 것이다.

코드는 이걸로 끝이다.

🥑

이번 포스트에선 useReducer + useContext로 API를 요청하고 응답을 관리하는 방법을 배웠다.
확실히 react-redux에 대해 이해하는 데에 엄청난 도움이 되었다. 내가 이걸 공부하지 않고 바로 react-redux를 배워서 그렇게 헤맸던 것이라고 확실한다.

여러분도 react-redux를 배울 요량이라면, 꼭 context API에 대해 선행학습 하길 바란다.

다음 포스팅은 뭘 할까 아직 정하지 못 했다. 배우고 싶은 게 너무 많기 때문이다.
우선은 react의 React.lazy 혹은 dynamic import에 대해 포스팅을 할까 싶다.

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글