본문으로 건너뛰기

안동민 개발노트

본문 시작
4장 : React 훅 기초실습

reducer·Context·데이터 요청

장바구니 reducer와 Context 및 데이터 요청 커스텀 훅으로 상태 계층을 구성합니다.

cartReducer.js (useReducer)

장바구니 상태를 관리하는 리듀서 함수를 정의합니다.

src/reducers/cartReducer.js
// 액션 타입 상수 정의
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const UPDATE_QUANTITY = 'UPDATE_QUANTITY';

// 초기 장바구니 상태
export const initialCartState = [];

// 리듀서 함수
export const cartReducer = (state, action) => {
  switch (action.type) {
    case ADD_ITEM:
      const existingItem = state.find(item => item.id === action.payload.item.id);
      if (existingItem) {
        // 이미 있는 상품이면 수량만 증가
        return state.map(item =>
          item.id === action.payload.item.id
            ? { ...item, quantity: item.quantity + action.payload.quantity }
            : item
        );
      } else {
        // 새 상품이면 목록에 추가
        return [
          ...state,
          { ...action.payload.item, quantity: action.payload.quantity }
        ];
      }
    case REMOVE_ITEM:
      return state.filter(item => item.id !== action.payload.id);
    case UPDATE_QUANTITY:
      return state.map(item =>
        item.id === action.payload.id
          ? { ...item, quantity: action.payload.quantity }
          : item
      ).filter(item => item.quantity > 0); // 수량이 0이하가 되면 제거

    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
};

CartContext.js (useContext)

장바구니 상태와 디스패치 함수를 전역적으로 제공하는 Context를 생성합니다.

src/contexts/CartContext.js
import React, { createContext, useReducer, useEffect } from 'react';
import { cartReducer, initialCartState } from '../reducers/cartReducer';

// 1. Context 객체 생성
export const CartContext = createContext();

// 2. 초기화 함수 (로컬 스토리지에서 장바구니 불러오기)
const initCart = (initialState) => {
  try {
    const savedCart = localStorage.getItem('shoppingCart');
    return savedCart ? JSON.parse(savedCart) : initialState;
  } catch (error) {
    console.error("Failed to parse cart from localStorage", error);
    return initialState;
  }
};

// 3. Provider 컴포넌트 생성
export const CartProvider = ({ children }) => {
  // useReducer와 지연 초기화(initCart)를 사용하여 장바구니 상태 관리
  const [cartState, dispatch] = useReducer(cartReducer, initialCartState, initCart);

  // cartState가 변경될 때마다 로컬 스토리지에 저장 (useEffect)
  useEffect(() => {
    localStorage.setItem('shoppingCart', JSON.stringify(cartState));
  }, [cartState]);

  // Context Provider를 통해 상태와 dispatch 함수 제공
  return (
    <CartContext.Provider value={{ cartState, dispatch }}>
      {children}
    </CartContext.Provider>
  );
};

Context와 reducer가 함께 움직이는 범위를 한 번 더 분리해 보면, 어느 컴포넌트가 읽고 어느 컴포넌트가 요청하는지 명확해집니다.


useFetch.js (Custom Hook)

API 호출 로직을 재사용 가능한 커스텀 훅으로 만듭니다.

src/hooks/useFetch.js
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const abortController = new AbortController(); // 요청 취소를 위한 AbortController
    const signal = abortController.signal;

    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url, { signal });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();
        setData(json);
      } catch (e) {
        if (e.name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          setError(e);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 요청 취소
    return () => {
      abortController.abort();
    };
  }, [url]); // url이 변경될 때마다 데이터를 다시 가져옵니다.

  return { data, loading, error };
}

export default useFetch;

useFetch는 성공 사례만 보는 훅이 아니라, 요청 시작부터 취소와 실패까지 화면 상태로 나누는 연습입니다.