React 2022년 두 번째 주말 #27

CoderS·2022년 1월 8일
0

리액트 Remind

목록 보기
27/32

#27 대청소날

Context 와 함께 사용하기

이번에는 리액트의 Context 와 API 연동을 함께하는 방법에 대해 알아보도록 하겠다.

컴포넌트에서 필요한 외부 데이터들은 컴포넌트 내부에서 useAsync 같은 Hook 을 사용해서 작업을 해도 충분하지만, 가끔씩 특정 데이터들은 다양한 컴포넌트에서 필요하게 될 때도 있다.

(예: 현재 로그인된 사용자의 정보, 설정 등)

그럴 때에는 Context 를 사용하면 개발이 편리해진다.

※ Context 준비하기

src 디렉토리에 UsersContext.js 라는 파일을 만들고, 다음과 같이 코드의 주석을 꼼꼼히 읽어가면서 코드를 따라 작성해보자!

UsersContext.js

import React, { createContext, useReducer, useContext } from 'react';

// UsersContext 에서 사용 할 기본 상태
const initialState = {
  users: {
    loading: false,
    data: null,
    error: null
  },
  user: {
    loading: false,
    data: null,
    error: null
  }
};

// 로딩중일 때 바뀔 상태 객체
const loadingState = {
  loading: true,
  data: null,
  error: null
};

// 성공했을 때의 상태 만들어주는 함수
const success = data => ({
  loading: false,
  data,
  error: null
});

// 실패했을 때의 상태 만들어주는 함수
const error = error => ({
  loading: false,
  data: null,
  error: error
});

// 위에서 만든 객체 / 유틸 함수들을 사용하여 리듀서 작성
function usersReducer(state, action) {
  switch (action.type) {
    case 'GET_USERS':
      return {
        ...state,
        users: loadingState
      };
    case 'GET_USERS_SUCCESS':
      return {
        ...state,
        users: success(action.data)
      };
    case 'GET_USERS_ERROR':
      return {
        ...state,
        users: error(action.error)
      };
    case 'GET_USER':
      return {
        ...state,
        user: loadingState
      };
    case 'GET_USER_SUCCESS':
      return {
        ...state,
        user: success(action.data)
      };
    case 'GET_USER_ERROR':
      return {
        ...state,
        user: error(action.error)
      };
    default:
      throw new Error(`Unhanded action type: ${action.type}`);
  }
}

// State 용 Context 와 Dispatch 용 Context 따로 만들어주기
const UsersStateContext = createContext(null);
const UsersDispatchContext = createContext(null);

// 위에서 선언한 두가지 Context 들의 Provider 로 감싸주는 컴포넌트
export function UsersProvider({ children }) {
  const [state, dispatch] = useReducer(usersReducer, initialState);
  return (
    <UsersStateContext.Provider value={state}>
      <UsersDispatchContext.Provider value={dispatch}>
        {children}
      </UsersDispatchContext.Provider>
    </UsersStateContext.Provider>
  );
}

// State 를 쉽게 조회 할 수 있게 해주는 커스텀 Hook
export function useUsersState() {
  const state = useContext(UsersStateContext);
  if (!state) {
    throw new Error('Cannot find UsersProvider');
  }
  return state;
}

// Dispatch 를 쉽게 사용 할 수 있게 해주는 커스텀 Hook
export function useUsersDispatch() {
  const dispatch = useContext(UsersDispatchContext);
  if (!dispatch) {
    throw new Error('Cannot find UsersProvider');
  }
  return dispatch;
}

우리가 만약에 id 를 가지고 특정 사용자의 정보를 가져오는 API 를 호출하고 싶다면 이런 형식으로 해주어야 한다.

dispatch({ type: 'GET_USER' });
try {
  const response = await getUser();
  dispatch({ type: 'GET_USER_SUCCESS', data: response.data });
} catch (e) {
  dispatch({ type: 'GET_USER_ERROR', error: e });
}

요청이 시작 했을때 액션을 디스패치해주고, 요청이 성공하거나 실패했을 때 또 다시 디스패치 해주는 것이다.

※ API 처리 함수 만들기

우리는 이러한 작업을 처리하는 함수를 만들어주겠다.

UsersContext.js 를 열어서 상단에 axios 를 불러오고, 코드의 하단 부분에 getUsers 와 getUser 함수를 작성해주면 된다.

이 함수들은 dispatch 를 파라미터로 받아오고, API 에 필요한 파라미터도 받아오게 된다.

import React, { createContext, useReducer, useContext } from 'react';
import axios from 'axios';

// (...)

export async function getUsers(dispatch) {
  dispatch({ type: 'GET_USERS' });
  try {
    const response = await axios.get(
      'https://jsonplaceholder.typicode.com/users'
    );
    dispatch({ type: 'GET_USERS_SUCCESS', data: response.data });
  } catch (e) {
    dispatch({ type: 'GET_USERS_ERROR', error: e });
  }
}

export async function getUser(dispatch, id) {
  dispatch({ type: 'GET_USER' });
  try {
    const response = await axios.get(
      `https://jsonplaceholder.typicode.com/users/${id}`
    );
    dispatch({ type: 'GET_USER_SUCCESS', data: response.data });
  } catch (e) {
    dispatch({ type: 'GET_USER_ERROR', error: e });
  }
}

자세히 보면 중복되는 코드들이 존재한다. 이 것은 나중에 리팩토링해주도록 하겠다.

※ Context 사용하기

이제 우리가 만든 Context 를 사용해보겠다. App 컴포넌트를 열어서 UsersProvider 로 감싸준다.

App.js

import React from 'react';
import Users from './Users';
import { UsersProvider } from './UsersContext';

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

export default App;

다음으로, Users 컴포넌트의 코드를 Context 를 사용하는 형태의 코드로 전환해본다.

Users.js

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

function Users() {
  const [userId, setUserId] = useState(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;

우리는 useUsersState() 와 useUsersDispatch() 를 사용해서 state 와 dispatch 를 가져오고, 요청을 시작할 때에는 getUsers() 함수 안에 dispatch 를 넣어서 호출을 해주었다.

코드를 저장하고 잘 작동되는지 확인해보라

그 다음으로 User 컴포넌트도 바꿔준다.

User.js

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

function User({ id }) {
  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;

여기서는 useEffect 를 사용해서 id 값이 바뀔 때마다 getUser() 함수를 호출해주도록 하면 된다. getUser() 함수를 호출 할 때에는 우리는 두 번째 파라미터에 현재 props 로 받아온 id 값을 넣어주었다.

※ 반복되는 코드를 줄이자!

이번에 우리가 배운 것은, Context + 비동기 API 연동의 정석이라고 볼 수 있다. 이 패턴에 대하여 잘 이해하고, 앞으로 이런 패턴을 잘 활용하면 된다.

여기서 더 나아가 반복되는 로직들을 함수화하여 재활용 하는 방법을 알아보겠다.

반복된 코드

export async function getUsers(dispatch) {
  dispatch({ type: 'GET_USERS' });
  try {
    const response = await axios.get(
      'https://jsonplaceholder.typicode.com/users'
    );
    dispatch({ type: 'GET_USERS_SUCCESS', data: response.data });
  } catch (e) {
    dispatch({ type: 'GET_USERS_ERROR', error: e });
  }
}

export async function getUser(dispatch, id) {
  dispatch({ type: 'GET_USER' });
  try {
    const response = await axios.get(
      `https://jsonplaceholder.typicode.com/users/${id}`
    );
    dispatch({ type: 'GET_USER_SUCCESS', data: response.data });
  } catch (e) {
    dispatch({ type: 'GET_USER_ERROR', error: e });
  }
}

위의 코드를 리팩토링 해보겠다. 우선, api 들이 들어있는 파일을 따로 분리해주고 src 디렉터리에 api.js 파일을 만들고 다음과 같이 코드를 적어주면 된다.

api.js

import axios from 'axios';

export async function getUsers() {
  const response = await axios.get(
    'https://jsonplaceholder.typicode.com/users'
  );
  return response.data;
}

export async function getUser(id) {
  const response = await axios.get(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );
  return response.data;
}

다음으로, src 디렉터리에 asyncActionUtils.js 라는 파일을 만들어주고 다음과 같이 코드를 작성해준다.

asyncActionUtils.js

// 이 함수는 파라미터로 액션의 타입 (예: GET_USER) 과 Promise 를 만들어주는 함수를 받아옵니다.
export default function createAsyncDispatcher(type, promiseFn) {
  // 성공, 실패에 대한 액션 타입 문자열을 준비합니다.
  const SUCCESS = `${type}_SUCCESS`;
  const ERROR = `${type}_ERROR`;

  // 새로운 함수를 만듭니다.
  // ...rest 를 사용하여 나머지 파라미터를 rest 배열에 담습니다.
  async function actionHandler(dispatch, ...rest) {
    dispatch({ type }); // 요청 시작됨
    try {
      const data = await promiseFn(...rest); // rest 배열을 spread 로 넣어줍니다.
      dispatch({
        type: SUCCESS,
        data
      }); // 성공함
    } catch (e) {
      dispatch({
        type: ERROR,
        error: e
      }); // 실패함
    }
  }

  return actionHandler; // 만든 함수를 반환합니다.
}

이렇게 createAsyncDispatcher 를 만들었으면, UsersContext 의 코드를 다음과 같이 리팩토링 할 수 있다.

UsersContext.js

import React, { createContext, useReducer, useContext } from 'react';
import createAsyncDispatcher from './createAsyncDispatcher';
import * as api from './api'; // api 파일에서 내보낸 모든 함수들을 불러옴

(...)

export const getUsers = createAsyncDispatcher('GET_USERS', api.getUsers);
export const getUser = createAsyncDispatcher('GET_USER', api.getUser);

코드가 훨씬 깔끔해졌다.

그리고 리듀서쪽 코드도 리팩토링을 할 수 있다. UsersContext 의 loadingState, success, error 를 잘라내서 asyncActionUtils.js 안에 붙여넣어준다.

그리고, 다음과 같이 initialAsyncState 객체를 만들어서 내보내고, createAsyncHandler 라는 함수도 만들어서 내보내준다.

asyncActionUtils.js

// 이 함수는 파라미터로 액션의 타입 (예: GET_USER) 과 Promise 를 만들어주는 함수를 받아옵니다.
export function createAsyncDispatcher(type, promiseFn) {
    // 성공, 실패에 대한 액션 타입 문자열을 준비합니다.
    const SUCCESS = `${type}_SUCCESS`;
    const ERROR = `${type}_ERROR`;
  
    // 새로운 함수를 만듭니다.
    // ...rest 를 사용하여 나머지 파라미터를 rest 배열에 담습니다.
    async function actionHandler(dispatch, ...rest) {
      dispatch({ type }); // 요청 시작됨
      try {
        const data = await promiseFn(...rest); // rest 배열을 spread 로 넣어줍니다.
        dispatch({
          type: SUCCESS,
          data
        }); // 성공함
      } catch (e) {
        dispatch({
          type: ERROR,
          error: e
        }); // 실패함
      }
    }
  
    return actionHandler; // 만든 함수를 반환합니다.
  }
  
  export const initialAsyncState = {
    loading: false,
    data: null,
    error: null
  };
  
  // 로딩중일 때 바뀔 상태 객체
  const loadingState = {
    loading: true,
    data: null,
    error: null
  };
  
  // 성공했을 때의 상태 만들어주는 함수
  const success = data => ({
    loading: false,
    data,
    error: null
  });
  
  // 실패했을 때의 상태 만들어주는 함수
  const error = error => ({
    loading: false,
    data: null,
    error: error
  });
  
  // 세가지 액션을 처리하는 리듀서를 만들어줍니다
  // type 은 액션 타입, key 는 리듀서서 사용할 필드 이름입니다 (예: user, users)
  export function createAsyncHandler(type, key) {
    // 성공, 실패에 대한 액션 타입 문자열을 준비합니다.
    const SUCCESS = `${type}_SUCCESS`;
    const ERROR = `${type}_ERROR`;
  
    // 함수를 새로 만들어서
    function handler(state, action) {
      switch (action.type) {
        case type:
          return {
            ...state,
            [key]: loadingState
          };
        case SUCCESS:
          return {
            ...state,
            [key]: success(action.data)
          };
        case ERROR:
          return {
            ...state,
            [key]: error(action.error)
          };
        default:
          return state;
      }
    }
  
    // 반환합니다
    return handler;
  }

이제 UsersContext 에서 방금 만든 initialAsyncState 와 createAsyncHandler 를 사용해서 코드를 고쳐준다.

UsersContext.js

import React, { createContext, useReducer, useContext } from 'react';
import {
  createAsyncDispatcher,
  createAsyncHandler,
  initialAsyncState
} from './asyncActionUtils';
import * as api from './api'; // api 파일에서 내보낸 모든 함수들을 불러옴

// UsersContext 에서 사용 할 기본 상태
const initialState = {
  users: initialAsyncState,
  user: initialAsyncState
};

const usersHandler = createAsyncHandler('GET_USERS', 'users');
const userHandler = createAsyncHandler('GET_USER', 'user');

// 위에서 만든 객체 / 유틸 함수들을 사용하여 리듀서 작성
function usersReducer(state, action) {
  switch (action.type) {
    case 'GET_USERS':
    case 'GET_USERS_SUCCESS':
    case 'GET_USERS_ERROR':
      return usersHandler(state, action);
    case 'GET_USER':
    case 'GET_USER_SUCCESS':
    case 'GET_USER_ERROR':
      return userHandler(state, action);
    default:
      throw new Error(`Unhanded action type: ${action.type}`);
  }
}

(...)

위 코드를 참고하면 각 요청에 대하여 3가지 (시작, 성공, 실패) 액션을 처리하는 함수를 만들어주었다.

하단의 switch 문에서는, 만약 return 또는 break 를 하지 않으면, 여러개의 case 에 대하여 동일한 코드를 실행한다.

예를 들자면 GET_USERS, GET_USERS_SUCCESS, GET_USERS_ERROR 액션이 발생하게 된다면 usersHandler(state, action) 을 호출해서 반환을 해준다.

참고 : 벨로퍼트와 함께하는 모던 리액트

느낀점 :

  • 오늘은 리액트의 Context 와 API 연동을 함께 하는 방법에 대해 알아보았다.
  • 생각보다 어렵고 복잡한 구조이지만 천천히 살펴보면서 이해하는게 중요하다.
profile
하루를 의미있게 살자!

0개의 댓글