reducer·Context·데이터 요청
장바구니 reducer와 Context 및 데이터 요청 커스텀 훅으로 상태 계층을 구성합니다.
cartReducer.js (useReducer)
장바구니 상태를 관리하는 리듀서 함수를 정의합니다.
// 액션 타입 상수 정의
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
export const RESTORE_CART = 'RESTORE_CART';
// 초기 장바구니 상태
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이하가 되면 제거
case RESTORE_CART:
return Array.isArray(action.payload.items)
? action.payload.items
: state;
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
};리듀서는 현재 상태와 액션만으로 다음 상태를 계산하는 순수 함수입니다. 같은 상품을 추가할 때와 처음 추가할 때처럼 조건에 따라 달라지는 전이를 표로 추적하면, 컴포넌트에서 빠진 예외를 찾기 쉽습니다.
| 액션 | 조건 | 다음 상태 | 확인할 불변식 |
|---|---|---|---|
ADD_ITEM |
같은 id가 이미 있음 |
해당 항목의 수량에 요청 수량을 더한 새 배열 | 동일 상품 행은 하나만 유지 |
ADD_ITEM |
같은 id가 없음 |
새 항목을 끝에 추가한 새 배열 | payload의 초기 수량을 사용 |
REMOVE_ITEM |
제거할 id를 받음 |
그 항목을 제외한 새 배열 | 다른 항목의 참조와 값은 보존 |
UPDATE_QUANTITY |
새 수량이 1 이상 | 대상 항목의 수량만 바꾼 새 배열 | 직접 mutation 없이 교체 |
UPDATE_QUANTITY |
새 수량이 0 이하 | 수량 갱신 뒤 해당 항목을 제거한 배열 | 0 이하 수량 항목은 남지 않음 |
RESTORE_CART |
Array.isArray()로 배열 여부를 확인함 |
배열 payload 또는 확인 실패 시 현재 상태 | reducer 안에서는 저장소를 읽지 않음 |
ADD_ITEM- 기존 상품 수량만 더하고 행은 하나로 유지한다.
- 새 상품 payload 수량과 함께 배열 끝에 추가한다.
REMOVE_ITEM- 받은
id를 제외한 새 배열을 반환한다. UPDATE_QUANTITY- 1 이상 대상 수량만 교체한다.
- 0 이하 갱신 뒤 항목을 제거한다.
RESTORE_CARTArray.isArray()가 참인 payload를 복원하고, 아니면 현재 상태를 지킨다.
CartContext.js (useContext)
장바구니 상태와 디스패치 함수를 전역적으로 제공하는 Context를 생성합니다.
import React, { createContext, useEffect, useReducer, useState } from 'react';
import {
cartReducer,
initialCartState,
RESTORE_CART,
} from '../reducers/cartReducer';
// 1. Context 객체 생성
export const CartContext = createContext(null);
// 2. Provider 컴포넌트 생성
export const CartProvider = ({ children }) => {
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
const [storageReady, setStorageReady] = useState(false);
// 브라우저 저장소 복원은 외부 시스템 동기화이므로 Effect에서 수행
useEffect(() => {
try {
const savedCart = localStorage.getItem('shoppingCart');
const parsedCart = savedCart ? JSON.parse(savedCart) : initialCartState;
dispatch({
type: RESTORE_CART,
payload: { items: Array.isArray(parsedCart) ? parsedCart : initialCartState },
});
} catch (error) {
console.error('Failed to restore cart from localStorage', error);
} finally {
setStorageReady(true);
}
}, []);
// 복원이 끝날 때까지 쓰기를 건너뛰어 초기 빈 배열로 기존 값을 덮지 않음
useEffect(() => {
if (!storageReady) return;
try {
localStorage.setItem('shoppingCart', JSON.stringify(cartState));
} catch (error) {
console.error('Failed to persist cart to localStorage', error);
}
}, [cartState, storageReady]);
// Context Provider를 통해 상태와 dispatch 함수 제공
return (
<CartContext.Provider value={{ cartState, dispatch }}>
{children}
</CartContext.Provider>
);
};이 예제는 Vite로 만든 브라우저 앱을 전제로 합니다. localStorage는 현재 origin의 브라우저 저장소이므로 서버의 공유 데이터 원본이 아닙니다. 서버 렌더링 환경으로 옮길 때도 저장소 접근은 클라이언트 Effect 안에 두고, 서버와 클라이언트의 첫 화면이 달라지지 않도록 별도의 복원 상태를 설계해야 합니다.
Context를 읽는 컴포넌트는 Provider의 value가 바뀌면 다시 렌더링됩니다. dispatch 자체의 식별자는 안정적이지만, 이 예제의 인라인 value 객체는 CartProvider가 렌더링될 때마다 새로 만들어집니다. 실제 측정에서 불필요한 소비자 렌더링이 문제가 되면 값을 메모화하거나, 상태 읽기와 액션 전달의 변경 빈도에 맞춰 Context를 나누는 방식을 고려합니다.
- 브라우저 저장소 복원
Effect가
localStorage문자열을 읽고 배열인지 검증한다. - reducer에 복원 요청
RESTORE_CART액션이 검증된 배열로 다음 상태를 계산한다. - Context로 한 경계에서 공유
Provider가
cartState와 안정된dispatch를 소비자에게 전달한다. - 소비자는 읽거나 action을 보냄
화면은 배열을 직접 고치지 않고 reducer 규칙을 통과시킨다.
- 복원 완료 상태부터 저장
storageReady전 쓰기를 건너뛰어 초기 빈 배열 덮어쓰기를 막고, 복원 완료 렌더부터 현재 상태를 직렬화한다.
localStorage는 Provider의 상태 원본이 아니라 브라우저 세션 사이의 복원 매체다. reducer는 순수 계산을 맡고, 저장소 읽기·쓰기는 Effect가 맡는다.useFetch.js (Custom Hook)
API 호출 로직을 재사용 가능한 커스텀 훅으로 만듭니다.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
const abortController = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
setData(null);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (!ignore) {
setData(json);
}
} catch (error) {
if (!ignore && error.name !== 'AbortError') {
setError(error);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
};
fetchData();
// 이전 Effect는 네트워크 작업과 늦은 상태 반영을 모두 중단
return () => {
ignore = true;
abortController.abort();
};
}, [url]);
return { data, loading, error };
}
export default useFetch;fetch는 404나 500 응답만으로 Promise를 reject하지 않으므로 response.ok를 직접 확인해야 합니다. response.json()의 파싱 실패도 같은 catch 경로로 들어가며, cleanup이 만든 AbortError는 사용자에게 보여 줄 오류에서 제외합니다. AbortController는 필요 없어진 네트워크 작업을 중단하고, ignore 플래그는 이전 Effect의 catch나 finally가 새 요청의 상태를 덮는 일을 막습니다.
이 훅은 Effect의 생명주기를 익히는 작은 예제입니다. 실제 제품에서는 캐시, 중복 요청 제거, 서버 데이터 로딩이 필요할 수 있으므로 사용 중인 프레임워크의 데이터 로더나 검증된 클라이언트 캐시를 우선 검토합니다.
- URL별 Effect setup
ignore=false로 시작하고 data와 error를 비운 뒤 loading을 연다. - 취소 가능한 요청
AbortController.signal을 fetch에 연결하고response.ok확인 뒤 JSON을 파싱한다. - cleanup 분기
URL 변경이나 unmount면 먼저
ignore=true로 만들고abort()한다. - 현재 Effect만 결과 반영
ignore가 false일 때만 data나 HTTP·네트워크·JSON 파싱 error를 쓰고 loading을 닫는다. - 이전 요청은 state를 쓰지 않음
늦은 reject나
finally가 새 요청의 loading을 false로 덮지 못한다.