이번 글에서는 리액트에서 말하는 상태의 정의 및 분류와 상태 관리를 잘 하기 위해 알아둬야할 것들을 정리해보았습니다.
1️⃣ 리액트의 상태
리액트 어플리케이션에서의 상태는 렌더링에 영향을 줄 수 있는 동적인 데이터 값을 의미합니다.
리액트 공식 문서의 정의는 아래와 같습니다.
" 렌더링 결과에 영향을 주는 정보를 담은 순수 자바스크립트 객체"
2️⃣ 상태의 분류
1) 지역 상태 (Local State)
컴포넌트 내부에서 사용되는 상태입니다. 체크 박스의 체크 여부, 폼의 입력값 등에 해당되며, 주로 useState 훅을 많이 사용합니다. 경우에 따라서는 useReducer를 사용하기도 합니다.
2) 전역 상태 (Global State)
앱 전체에서 공유되는 상태를 의미합니다. 여러 개의 컴포넌트가 전역 상태를 사용할 수 있으며 상태가 변경되면 컴포넌트들도 업데이트 됩니다. Prop drilling 문제를 피하고자 지역 상태를 해당 컴포넌트들 사이의 전역 상태로 공유할 수도 있습니다.
Prop drilling이란?
props를 통해 데이터를 전달하는 과정에서 중간 컴포넌트는 해당 데이터가 필요하지 않음에도 자식 컴포넌트에 전달하기 위해 props를 전달해야하는 과정을 의미합니다. 컴포넌트 수가 많을수록 Prop drilling으로 인해 코드가 훨씬 복잡해질 수 있습니다.
3) 서버 상태 (Server State)
사용자의 정보, 글 목록 등 외부 서버에 저장해야 하는 상태들을 의미합니다. UI 상태와 결합해 관리하게 되며 로딩 여부나 에러 상태 등이 포함됩니다.
서버 상태는 지역 상태 혹은 전역 상태와 동일한 방법으로 관리되며, 최근에는 react-query, SWR 과 같은 외부 라이브러리를 사용하여 관리합니다.
3️⃣상태 관리를 잘 하는 방법
상태는 애플리케이션의 복잡성을 증가시키고, 동작은 예측하기 어렵게 만듭니다. 또한, 상태가 업데이트 될 때마다 리렌더링이 발생하므로, 유지보수와 성능 관점에서 상태의 개수는 최소화하는 것이 바람직 합니다.
즉, 가능하면 Stateless 컴포넌트를 활용하는게 좋습니다.
4️⃣상태를 정의할 때 고려해야할 2가지
어떤 값을 상태로 정의할 때는 다음 2가지 사항을 고려해야 합니다.
1. 시간이 지나도 변하지 않는다면 상태가 아니다.
2. 파생된 값은 상태가 아니다.
1) 시간이 지나도 변하지 않는다면 상태가 아니다. → 객체 참조 동일성 유지
시간이 지나도 변하지 않는 값이라면, 객체 참조 동일성을 유지하는 방법을 고려해볼 수 있습니다.
컴포넌트 마운트 시에만 스토어 객체 인스턴스를 생성하고, 컴포넌트 언마운트까지 해당 참조가 변하지 않는다고 가정해보겠습니다. 이를 단순히 상수 변수에 저장하여 사용할 수도 있지만, 이 방식은 렌더링 될 때마다 새로운 객체 인스턴스가 생성되기 때문에 props 등으로 전달 시 매번 다른 객체로 인식되어 불필요한 리렌더링이 발생할 수 있습니다.
따라서, 컴포넌트 라이프사이클 내에서 마운트 될 때 인스턴스가 생성되고, 렌더링 될 때마다 동일한 객체 참조가 유지되도록 구현해주어야 합니다.
그럼, 어떤 방식으로 구현할 수 있고 어떤 방법을 사용하는게 가장 나을까요? 아래 정리해보았습니다.
방법 1. useMemo를 이용한 메모이제이션
useMemo를 활용하면 컴포넌트가 마운트 될 때만 객체 인스턴스를 생성하고 이후 렌더링에서는 이전 인스턴스를 재활용할 수 있도록 구현 가능합니다. 즉, 객체 참조의 동일성 유지를 위해 널리 사용되는 방법 중 하나 입니다.
const store = useMemo(() => new Store(), []);
하지만 공식 문서를 살펴보면, useMemo를 통한 메모이제이션은 의미상으로 보장된 것이 아니므로, 오로지 성능 향상을 위해서만 사용해야한다고 되어있습니다.
또한, 리액트에선 메모리 확보를 위해 이전 메모이제이션 데이터가 삭제될 수 있다고 합니다.
따라서 useMemo를 사용한다면, useMemo 없이도 코드가 올바르게 작동하도록 작성한 후, 추후 성능 개선을 위해 useMemo를 추가하는 것이 적절한 접근 방식 입니다.
방법 2. useState의 초깃값만 지정하는 방법
useState의 초깃값만 지정함으로써 모든 렌더링 과정에서 객체 참조를 동일하게 유지할 수 있습니다.
그러나 useState(new Store())와 같이 사용하면 객체 인스턴스가 실제로 사용되지 않더라도 렌더링 마다 생성되어 초깃값 설정에는 큰 비용이 소요될 수 있습니다.
따라서 useState(()=> new Store())와 같이 초깃값을 계산하는 콜백을 지정하는 방식을 사용해야 합니다. (지연 초기화 방식)
const [store] = useState(() => new Store());
하지만 useState 사용은 기술적으론 잘 동작하더라도 의미론적으로는 좋은 방법이 아닙니다. 원래는 상태를 시간이 지나면서 변화되어 렌더링에 영향을 주는 데이터로 정의했지만, 현재의 목적은 모든 렌더링 과정에서 객체의 참조를 동일하게 유지하고자 하는 것이기 때문입니다.
방법 3. useRef 이용하기 (권장)
공식 문서에 따르면 useRef가 동일한 객체 참조를 유지하려는 목적으로 사용하기에는 가장 적합한 훅 입니다.
useRef의 인자로 직접 new Store()을 사용하면 useState와 마찬가지로 렌더링마다 불필요한 인스턴스가 생성되므로 아래와 같이 작성되어야 합니다.
const store = useRef<Store>(null);
if(!store.current) {
store.current = new Store();
}
useRef는 기술적으로 useState({children : initialValue})[0]과 동일하다고 할 수 있습니다.
하지만 상태라고 하는 것은 렌더링에 영향을 주며 변화하는 값을 의미하므로, 의미론적으로는 객체 참조 동일성 유지를 위해 useState에 초깃값만 할당하는 것은 적절하지 않습니다.
가독성 등의 이유료 팀 내에서 합의된 컨벤션으로 지정된 경우가 아니라면, 동일 객체 참조를 할 땐 useRef를 사용할 것을 권장합니다.
// useRef 사용 예시 - 구체적인 스토어 객체
type Store = {
data: any[];
addItem: (item: any) => void;
removeItem: (id: number) => void;
};
const Component = () => {
// 👇 useRef를 통해 객체 참조 유지
const storeRef = useRef<Store | null>(null);
// 최초 렌더링 시 한 번만 Store 인스턴스 생성
if (storeRef.current === null) {
storeRef.current = {
data: [],
addItem: (item) => {
storeRef.current!.data.push(item);
console.log('Item added', storeRef.current!.data);
},
removeItem: (id) => {
storeRef.current!.data = storeRef.current!.data.filter(
item => item.id !== id
);
console.log('Item removed', storeRef.current!.data);
}
};
}
// 렌더링마다 동일한 Store 인스턴스 참조
const store = storeRef.current;
return (
<div>
<button onClick={() => store.addItem({ id: Date.now(), name: 'New item' })}>
아이템 추가
</button>
<button onClick={() => store.removeItem(store.data[0]?.id)}>
첫 번째 아이템 삭제
</button>
<pre>{JSON.stringify(store.data, null, 2)}</pre>
</div>
);
};
2) 파생된 값은 상태가 아니다.
좀 더 자세히 말하면, props거나 기존 상태에서 계산될 수 있는 값은 상태가 아닙니다.
1. 부모에게서 props로 전달받으면 상태가 아닙니다.
2. 기존 상태에서 계산할 수 있는 값은 상태가 아닙니다.
다른 값에서 파생된 값을 상태로 관리하게 되면 기존 출처와는 다른 새로운 출처에서 관리하게 되는 것이므로 해당 데이터의 정확성과 일관성을 보장하기 어렵습니다.
각 문장의 의미를 예시 코드를 보며 좀 더 자세히 살펴보겠습니다.
1. 부모에게서 props로 전달받으면 상태가 아닙니다.
아래와 같은 컴포넌트가 있다고 가정해볼게요. 초기 이메일 값을 부모 컴포넌트로부터 받아 input value로 렌더링하고 이후에는 사용자가 입력한 값을 input 태그의 value로 렌더링합니다.
// 👇 부모로부터 initialEmail을 props로 받아서 사용하는 예시
type UserEmailProps = {
initialEmail: string;
};
const UserEmail = ({ initialEmail }: UserEmailProps) => {
// 👇 부모로부터 받은 initialEmail을 useState의 초깃값으로 설정
const [email, setEmail] = useState(initialEmail);
return (
<div>
<label htmlFor="email">이메일:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
);
};
// 👇 사용 예시
const ParentComponent = () => {
const [userEmail, setUserEmail] = useState("user@example.com");
return (
<div>
<button onClick={() => setUserEmail("new@example.com")}>
이메일 변경
</button>
<UserEmail initialEmail={userEmail} />
{/*
👆 버튼 클릭 시 userEmail이 변경되어도
UserEmail 컴포넌트의 input 값은 변경되지 않음
*/}
</div>
);
};
위 컴포넌트에서 전달받은 initialEmail prop 값이 변경되어도 input 태그의 value는 변경되지 않습니다.
useState의 초깃값으로 설정한 값은 컴포넌트가 마운트될 때 한 번만 email 상태의 값으로 설정되며 이후에는 독자적으로 관리됩니다.
여기서 props와 상태를 동기화하기 위해 useEffect를 사용한 해결책을 떠올릴 수도 있지만, 좋은 방법은 아닙니다.
사용자가 값을 변경한 뒤에 initialEmail prop이 변경된다면 input 태그의 value는 어떻게 설정될까요?
이럴 때는 사용자의 입력을 무시하고 부모 컴포넌트로부터 전달된 initialEmail props의 값을 value로 설정할 것입니다.
useEffect를 사용한 동기화 작업은 리액트 외부 데이터와 동기화할 때만 사용해야하며, 내부에 존재하는 데이터를 동기화하는 데는 사용하면 안됩니다.
내부에 존재하는 상태를 useEffect로 동기화하면 추적하기 어려운 오류가 발생할 수 있기 때문입니다.
const [email, setEmail] = useState(initialEmail);
useEffect(() => {
setEmail(initialEmail);
}, [initialEmail]);
현재 email 상태에 대한 출처는 prop 받는 initialEmail과 useState로 생성한 email state 입니다.
문제 해결을 위해서는 두 출처의 데이터를 동기화하기보다 단일 출처로부터 데이터를 사용하도록 변경해주어야 합니다.
이 때, 일반적으로 리액트에서는 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기(Lifting State Up) 기법을 사용합니다.
UserEmail에서 관리하던 상태를 부모 컴포넌트로 옮겨서 email 데이터의 출처를 props 하나로 통일할 수 있습니다.
이처럼 두 컴포넌트에서 동일한 데이터를 상태로 갖고 있을 경우엔 두 컴포넌트 간의 상태를 동기화하는 방법이 아닌, 가까운 공통 부모 컴포넌트로 상태를 끌어올려 SSOT를 지킬 수 있도록 해야 합니다.
※ SSOT(Single Source of Truth) : 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론.
2. 기존 상태에서 계산할 수 있는 값은 상태가 아닙니다.
아래 코드는 아이템 목록과 선택된 아이템 목록을 가진 코드 입니다. 이 코드는 아이템 목록 변경 시마다 선택된 아이템 목록을 가져오기 위해 useEffect로 동기화 작업을 하고 있습니다.
// 👇 잘못된 사용 예시: 파생 데이터를 별도의 상태로 관리
type Item = {
id: number;
name: string;
isSelected: boolean;
};
const ItemList = () => {
const [items, setItems] = useState<Item[]>([
{ id: 1, name: "아이템 1", isSelected: false },
{ id: 2, name: "아이템 2", isSelected: true },
{ id: 3, name: "아이템 3", isSelected: false },
]);
// 👇 items로부터 계산 가능한 값을 별도 상태로 관리하면 동기화 문제 발생
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
// 👇 useEffect로 동기화 시도 (좋지 않은 방법)
useEffect(() => {
setSelectedItems(items.filter(item => item.isSelected));
}, [items]);
const toggleSelection = (id: number) => {
setItems(
items.map(item =>
item.id === id ? { ...item, isSelected: !item.isSelected } : item
)
);
// 👆 여기서 items가 변경되면, useEffect가 실행되어 selectedItems 상태도 변경됨
// 이로 인해 2번의 렌더링이 발생
};
return (
<div>
<h3>전체 아이템 ({items.length})</h3>
<ul>
{items.map(item => (
<li
key={item.id}
style={{
cursor: 'pointer',
fontWeight: item.isSelected ? 'bold' : 'normal'
}}
onClick={() => toggleSelection(item.id)}
>
{item.name} {item.isSelected ? '✓' : ''}
</li>
))}
</ul>
<h3>선택된 아이템 ({selectedItems.length})</h3>
<ul>
{selectedItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};
이 코드의 가장 큰 문제는 items와 selectedItems가 동기화되지 않을 수 있다는 것 입니다.
이 코드와 같이 아주 간단한 로직을 담고 있다면 괜찮지만, 여러 상태가 복잡하게 얽힐 경우 흐름 파악이 어렵고, 의도치 않게 동기화 과정이 누락될 수도 있습니다. 또한 setSelectedItem을 사용하여 items에서 가져온 데이터가 아닌 임의의 데이터 셋을 설정하는 것도 가능하므로 오류 발생 가능성도 있습니다.
items와 seletedItems 라는 2가지 상태를 유지하면서 useEffect로 동기화하는 과정을 거치면 selectedItems 값을 얻기위해 2번의 렌더링이 발생합니다.
정리하면, 새로운 상태로 정의함으로써 단일 출처가 아닌 여러 출처를 가지게 되었고, 이에 따라 동기화 문제가 발생하게 된다는 것입니다. 따라서, 하나의 출처로 합치는 방향으로 코드를 수정해야합니다. 아래 그 방법을 알아보겠습니다.
계산된 값을 자바스크립트 변수로 담기
상태로 정의하지 않고, 계산된 값을 자바스크립트 변수로 담으면 items가 변경될 때마다 컴포넌트가 새로 렌더링되고, 매번 렌더링될 때마다 selectedItems를 다시 계산하게 됩니다.
즉, 단일 출처를 가지면서 원하는 동작을 수행하게 할 수 있습니다.
const [items, setItems] = useState<Item[]>([]);
const selectedItems = items.filter((item) => item.isSelected);
계산할 수 있는 값을 상태로 관리하지 않고 직접 자바스크립트 변수에 계산 결과를 담으면 기존의 렌더링 횟수를 줄일 수 있지만, 매번 렌더링 시마다 계산이 수행되어, 수행되는 계산의 비용이 크다면 성능 문제가 발생할 수 있습니다.
계산 비용이 크다면? useMemo
이때는 useMemo를 사용해 items이 변경될 때만 계산을 수행하고 결과를 메모이제이션 함으로써 성능을 개선할 수 있습니다.
const [items, setItems] = useState<Item[]>([]);
const selectedItems = useMemo(() => veryExpensiveCalculation(items), [items]);
5️⃣ useState 대신 useReducer가 권장되는 경우
상태관리 시, 어떨 때 useState를 사용하고 useReducer를 사용해야할까요?
useState 대신 useReducer 사용이 권장되는 경우는 크게 2가지 입니다. 각각의 경우를 예시 코드를 통해 살펴보겠습니다.
1. 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
2. 다음 상태가 이전 상태에 의존적일 때
1) 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
예를 들어 리뷰 리스트를 필터링하여 보여주기 위한 쿼리를 상태로 저장해야한다면, 이러한 쿼리는 검색 날짜 범위, 리뷰 점수, 키워드 등 많은 하위 필드를 가지게 됩니다.
페이지네이션을 고려한다면, 페이지, 사이즈 등의 필드도 추가될 수 있습니다.
// 👇 복잡한 상태 구조: 리뷰 필터링 쿼리
type ReviewQuery = {
keyword: string;
minScore: number;
maxScore: number;
startDate: string | null; // ISO 형식 '2023-01-01'
endDate: string | null;
page: number;
size: number;
};
// useState로 구현한 경우 (권장하지 않음)
const ReviewListWithState = () => {
const [query, setQuery] = useState<ReviewQuery>({
keyword: '',
minScore: 0,
maxScore: 5,
startDate: null,
endDate: null,
page: 0,
size: 10
});
// 👇 페이지 변경 시 - 기존 상태를 복사하고 page만 수정
const handlePageChange = (newPage: number) => {
setQuery({
...query,
page: newPage
});
};
// 👇 페이지 크기 변경 시 - 기존 상태를 복사하고 size를 수정하며, page는 0으로 초기화
const handleSizeChange = (newSize: number) => {
setQuery({
...query,
size: newSize,
page: 0 // 👈 size가 변경되면 page를 0으로 초기화해야 함 (비즈니스 로직)
});
};
// 👇 키워드 변경 시 - 복잡한 업데이트 로직
const handleKeywordChange = (newKeyword: string) => {
setQuery({
...query,
keyword: newKeyword,
page: 0 // 👈 검색어가 변경되면 page를 0으로 초기화해야 함 (비즈니스 로직)
});
};
// 다른 상태 변경 함수들...
return (
<div>
{/* UI 구현 */}
</div>
);
};
이러한 데이터 구조를 useState로 다루면, 상태를 업데이트할 때마다 잠재적인 오류 가능성이 증가합니다.
페이지 값만 업데이트하고 싶어도 우선 전체 데이터를 가지고 온 다음 페이지값을 덮어쓰게 되므로, 사이즈나 필터 같은 다른 필드가 수정될 수 있어 의도치 않은 오류가 발생할 수 있습니다.
또한 '사이즈 필드를 업데이트 할 때는 페이지 필드를 0으로 설정해야 한다.' 등의 특정한 업데이트 규칙이 있다면 useState 만으로는 한계가 있습니다. 이럴 경우 useReducer를 사용하는 것이 좋습니다.
useReducer 적용
useReducer는 무엇을 변경할지와 어떻게 변경할지를 분리해, dispatch를 통해 어떤 작업을 할지 액션으로 넘기고, reducer 함수 내에서 상태를 업데이트하는 방식을 정의합니다.
이를 통해 복잡한 상태 로직을 숨기고 안전성을 높일 수 있습니다. 아래는 앞에서 본 리뷰 쿼리 대해 useReducer를 적용한 코드입니다.
// 👇 useReducer로 개선한 버전
// 액션 타입 정의
type ReviewQueryAction =
| { type: 'SET_KEYWORD'; payload: string }
| { type: 'SET_SCORE_RANGE'; payload: { min: number; max: number } }
| { type: 'SET_DATE_RANGE'; payload: { start: string | null; end: string | null } }
| { type: 'SET_PAGE'; payload: number }
| { type: 'SET_SIZE'; payload: number }
| { type: 'RESET_FILTERS' };
// 리듀서 함수
const reviewQueryReducer = (state: ReviewQuery, action: ReviewQueryAction): ReviewQuery => {
switch (action.type) {
case 'SET_KEYWORD':
return {
...state,
keyword: action.payload,
page: 0 // 👈 검색어 변경 시 페이지 초기화 로직 포함
};
case 'SET_SCORE_RANGE':
return {
...state,
minScore: action.payload.min,
maxScore: action.payload.max,
page:
2) 다음 상태가 이전 상태에 의존적일 때
boolean 상태를 토글하는 액션만 사용하는 경우에도 useState 대신 useReducer를 사용하곤 합니다. 이유가 뭘까요?
useState와 달리 useReducer는 상태 업데이트 로직을 분리할 수 있고, 이전 상태에 의존하는 로직을 더 안전하게 처리할 수 있습니다.
useState를 사용하여 이전 상태에 의존하는 업데이트를 할 때는 함수형 업데이트를 사용해야 하는데, 이것을 실수로 놓치면 버그가 발생할 수 있습니다.
// useState로 토글 구현 (함수형 업데이트 사용)
const [isOpen, setIsOpen] = useState(false);
// 올바른 방법
const toggle = () => setIsOpen(prev => !prev);
// 잘못된 방법 (비동기 업데이트 시 문제 발생 가능)
const toggleIncorrect = () => setIsOpen(!isOpen);
useReducer를 사용하면 이런 문제를 방지할 수 있습니다:
// useReducer로 토글 구현
const [isOpen, dispatch] = useReducer(state => !state, false);
// 항상 이전 상태를 기반으로 업데이트됨
const toggle = () => dispatch();
또한 useReducer는 복잡한 상태 로직을 테스트하기 쉽게 만들고, 컴포넌트와 분리된 순수 함수로 상태 변환 로직을 관리할 수 있게 해줍니다.
6️⃣ 전역 상태 관리와 상태 관리 라이브러리
1) 컨텍스트 API + 'useState 또는 useReducer'
컨텍스트 API는 다른 컴포넌트들과 데이터를 쉽게 공유하기 위한 목적으로 사용되는 API이며, Prop Drilling과 같은 문제를 해결하기 위한 도구로 활용됩니다. UI 테마 정보나 로케일 데이터*와 같이 전역적으로 제공해야 할 때도 유용하게 사용할 수 있습니다.
로케일 데이터란?
로케일 데이터는 애플리케이션의 지역화(localization)와 국제화(internationalization)를 위한 정보를 의미합니다. 이는 다음과 같은 요소들을 포함합니다:
- 언어 설정 - 사용자 인터페이스에 표시될 텍스트의 언어(예: 한국어, 영어, 일본어 등)
- 날짜 및 시간 형식 - 지역에 따라 다른 날짜 표시 방식(예: MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD)
- 숫자 형식 - 천 단위 구분자, 소수점 표시(예: 1,000.00 vs 1.000,00)
- 통화 형식 - 통화 기호 및 위치(예: ₩1,000, $1,000, 1.000€)
- 측정 단위 - 미터법 또는 영국식 단위계 등
웹 또는 앱 개발에서 로케일 데이터는 일반적으로 전역 상태로 관리되며, React에서는 컨텍스트 API를 사용하여 이 데이터를 애플리케이션 전체에 제공하는 경우가 많습니다. 이렇게 하면 사용자가 언어나 지역 설정을 변경했을 때 모든 컴포넌트가 일관되게 업데이트될 수 있습니다.
예를 들어, 국제화 라이브러리인 i18next나 react-intl 등을 사용할 때 로케일 데이터를 컨텍스트를 통해 전달하는 패턴이 흔히 사용됩니다.
컨텍스트 API를 활용해 전역적으로 공유해야 하는 데이터를 컨텍스트로 제공하고 해당 컨텍스트를 구독한 컴포넌트에서만 데이터를 읽을 수 있게 됩니다.
예시 코드를 살펴보겠습니다. TabGroup 컴포넌트와 Tab 컴포넌트에 type이라는 prop을 전달할 경우, Tab Group 컴포넌트에만 이 prop을 전달하고 Tab 컴포넌트의 구현 내에서도 사용할 수 있게 하려면 어떻게 해야 할까요?
// TabContext.js
import { createContext, useContext } from 'react';
// 🔍 컨텍스트 API: 컴포넌트 간 데이터 공유를 위한 기본 생성
export const TabContext = createContext(null); // ⭐ 새로운 컨텍스트 객체 생성
// ⚙️ 커스텀 훅: 컨텍스트를 쉽게 사용하기 위한 래퍼
export const useTabContext = () => {
const context = useContext(TabContext); // 📌 React의 useContext 훅을 사용하여 컨텍스트 값 접근
if (!context) {
// 🛑 에러 처리: 컨텍스트가 Provider 외부에서 사용될 경우
throw new Error('useTabContext must be used within a TabProvider');
}
return context;
};
아래와 같이 상위 컴포넌트 구현 부에 컨텍스트 프로바이더(Context Provider)를 넣어주고, 하위 컴포넌트에서 해당 컨텍스트를 구독하여 데이터를 읽어오는 방식으로 구현할 수 있습니다.
// TabGroup.js
import { TabContext } from './TabContext';
// 🔝 상위 컴포넌트: 컨텍스트 프로바이더를 포함하는 부모 컴포넌트
export function TabGroup({ children, type }) {
return (
// 🔄 컨텍스트 공유: Provider를 통해 type 값을 하위 컴포넌트에 전달
<TabContext.Provider value={{ type }}> // ⭐ 프로바이더로 값 제공
<div className={`tab-group tab-group-${type}`}>
{children}
</div>
</TabContext.Provider>
);
}
// Tab.js
import { useTabContext } from './TabContext';
// 🔽 하위 컴포넌트: 컨텍스트를 구독하여 값을 사용
export function Tab({ children }) {
const { type } = useTabContext(); // 📌 커스텀 훅을 통해 상위에서 제공된 type 값 접근
return (
<div className={`tab tab-${type}`}> // 🎨 상위에서 받은 type 값으로 스타일 적용
{children}
</div>
);
}
// 사용 예시
function App() {
return (
// 🧩 실제 사용: 상위 컴포넌트에만 prop 전달, 하위는 컨텍스트로 접근
<TabGroup type="primary"> // ⭐ type prop은 상위 컴포넌트에만 전달
<Tab>Tab 1</Tab> // 💡 Tab에는 직접 prop을 전달하지 않아도 됨
<Tab>Tab 2</Tab>
</TabGroup>
);
}
유틸리티 함수를 정의하여 더 간단한 코드로 컨텍스트와 훅을 생성할 수도 있습니다. 아래와 같이 createContextHook이라는 유틸리티 함수를 정의해서 자주 사용되는 프로바이더와 해당 컨텍스트를 사용하는 훅을 간편하게 생성할 수도 있습니다.
// createContextHook.js
import { createContext, useContext } from 'react';
// 🛠️ 유틸리티 함수: 컨텍스트와 훅을 한 번에 생성
export function createContextHook(name) {
const Context = createContext(null); // ⭐ 새로운 컨텍스트 객체 생성
const useContextHook = () => {
const context = useContext(Context); // 📌 컨텍스트 값 접근
if (!context) {
// 🛑 에러 처리
throw new Error(`use${name}Context must be used within a ${name}Provider`);
}
return context;
};
// 🔄 배열 형태로 반환하여 구조분해할당 가능
return [Context, useContextHook];
}
// 사용 예시
// 🚀 간결한 방식: 한 줄로 컨텍스트와 훅 생성
const [TabContext, useTabContext] = createContextHook('Tab'); // ⭐ 유틸리티 함수 사용
// ✨ 컨텍스트 프로바이더 컴포넌트 생성
export function TabProvider({ children, value }) {
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
}
export { useTabContext };
컨텍스트 API는 전역 상태를 관리하기 위한 솔루션보다는 여러 컴포넌트 간에 값을 공유하는 솔루션에 가깝습니다.
하지만 아래 코드처럼 useState나 useReducer와 같은 지역 상태를 관리하기 위한 API와 결합하여 여러 컴포넌트 사이에서 상태를 공유하기 위한 방법으로 사용되기도 합니다.
// ThemeContext.js
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null); // ⭐ 테마를 위한 컨텍스트 생성
// 🔄 컨텍스트 API + useState 결합: 상태 관리와 공유를 함께
export function ThemeProvider({ children }) {
// 📊 useState: 로컬 상태 관리를 위한 훅 사용
const [theme, setTheme] = useState('light'); // ⭐ 상태 관리 추가
// 🎮 토글 함수: 상태 업데이트를 위한 함수 정의
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); // 👆 함수형 업데이트
};
// 📦 컨텍스트에 제공할 값 패키징
const value = {
theme,
toggleTheme // ⚡ 상태와 상태 변경 함수를 모두 제공
};
return (
// 🔄 컨텍스트 제공: 상태와 상태 변경 함수를 모두 포함
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// ⚙️ 커스텀 훅: 테마 컨텍스트 접근
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// 사용 예시
function App() {
return (
// 🔄 전역 테마 제공
<ThemeProvider>
<ThemedComponent />
</ThemeProvider>
);
}
// 🎨 테마를 사용하는 컴포넌트
function ThemedComponent() {
// 📌 컨텍스트를 통해 테마 상태와 변경 함수에 접근
const { theme, toggleTheme } = useTheme(); // ⭐ 커스텀 훅으로 상태와 함수에 접근
return (
<div className={`themed-component ${theme}`}> // 🎨 테마 적용
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button> // 👆 테마 토글 이벤트
</div>
);
}
그러나 컨텍스트 API를 사용하여 전역 상태 관리를 하는 것은 대규모이거나 성능이 중요한 애플리케이션에서는 권장되지 않는 방법입니다. 왜냐하면, 컨텍스트 프로바이더의 props로 주입된 값이나 참조가 변경될 때마다 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링 되기 때문입니다.
컴포넌트 생성 시 관심사를 잘 분리해서 구성하면 최소화할 수 있는 문제지만, 규모가 커질수록, 전역 상태가 많아질수록 불필요한 상태 복잡도가 증가하게 됩니다.
2) 외부 상태 관리 라이브러리 (Redux, MobX, Recoil, Zustand 등)
외부 상태 관리 라이브러리는 복잡한 상태 관리를 위한 더 강력한 도구를 제공합니다. 이러한 라이브러리들은 React의 Context API보다 더 효율적인 렌더링 최적화, 개발자 도구, 미들웨어 지원 등의 기능을 제공합니다.
Redux
Redux는 예측 가능한 상태 컨테이너로, 단일 스토어와 리듀서 패턴을 사용합니다.
// Redux 스토어 설정
import { createStore } from 'redux';
// 리듀서
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state;
}
};
// 스토어 생성
const store = createStore(counterReducer);
// 컴포넌트에서 사용
import { Provider, useSelector, useDispatch } from 'react-redux';
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
);
}
MobX
MobX는 반응형 프로그래밍 접근법을 사용하는 상태 관리 라이브러리입니다.
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
// 스토어 클래스
class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
increment() {
this.count += 1;
}
decrement() {
this.count -= 1;
}
}
const counterStore = new CounterStore();
// 컴포넌트에서 사용 (observer HOC로 감싸기)
const Counter = observer(() => {
return (
<div>
<p>Count: {counterStore.count}</p>
<button onClick={() => counterStore.increment()}>+</button>
<button onClick={() => counterStore.decrement()}>-</button>
</div>
);
});
Recoil
Recoil은 React를 위해 설계된 상태 관리 라이브러리로, 원자(atoms)와 선택자(selectors) 개념을 사용합니다.
import { RecoilRoot, atom, useRecoilState } from 'recoil';
// 원자(atom) 정의
const counterState = atom({
key: 'counterState',
default: 0
});
// 컴포넌트에서 사용
function App() {
return (
<RecoilRoot>
<Counter />
</RecoilRoot>
);
}
function Counter() {
const [count, setCount] = useRecoilState(counterState);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}
Zustand
Zustand는 간결한 API를 가진 경량 상태 관리 라이브러리입니다.
import create from 'zustand';
// 스토어 생성
const useStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 }))
}));
// 컴포넌트에서 사용
function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
3) Context API vs 외부 라이브러리 비교
특징 Context API 외부 상태 관리 라이브러리
설정 복잡성 | 낮음 (React 내장) | 중간~높음 (추가 설치 및 설정 필요) |
성능 | 제한적 (컨텍스트 변경 시 모든 소비자 리렌더링) | 최적화됨 (선택적 구독, 메모이제이션 등) |
개발자 도구 | 제한적 | 풍부함 (시간 여행 디버깅, 상태 검사 등) |
미들웨어 지원 | 없음 | 있음 (비동기 작업, 로깅, 지속성 등) |
학습 곡선 | 낮음 | 중간~높음 |
보일러플레이트 | 적음 | 중간~많음 (Redux > MobX/Recoil > Zustand) |
확장성 | 제한적 | 높음 |
외부 상태 관리 라이브러리가 더 적합한 경우
- 복잡한 상태 로직을 처리할 때
- 많은 컴포넌트가 동일한 상태에 접근해야 할 때
- 성능 최적화가 중요할 때
- 시간 여행 디버깅과 같은 고급 개발자 도구가 필요할 때
- 로깅, 지속성, 비동기 작업과 같은 미들웨어 기능이 필요할 때
Context API가 더 적합한 경우
- 간단한 전역 상태를 관리할 때
- 컴포넌트 트리 깊숙한 곳에 props를 전달하는 것을 피하고 싶을 때
- 추가 라이브러리 없이 React만으로 해결하고 싶을 때
- 앱의 규모가 작거나 상태 변경이 자주 일어나지 않을 때
최근에는 Redux의 복잡성을 줄인 Redux Toolkit과 같은 도구나, Zustand와 같은 간결한 API를 가진 라이브러리가 인기를 얻고 있어 외부 라이브러리 사용의 진입 장벽이 낮아지고 있습니다.