useContext
에 관해 이해하기 앞서 context
가 무엇인지부터 알아보겠습니다.
리액트 애플리케이션은 부모 컴포넌트와 자식 컴포넌트로 이루어져 있습니다. 이에, 부모 컴포넌트에서 사용하고 있는 데이터를 자식 컴포넌트에서도 사용하려면 props
로 넘겨줘야 합니다.
<Parent >
<Children props={date}/>
</Parent>
그런데 부모 컴포넌트와 데이터를 전달받는 자식 컴포넌트 사이의 거리가 멀면 props를 그 거리만큼 계속해서 전달해줘야 합니다. 마치 아래의 예제처럼 말이죠. 이러한 기법을 prop 내려주기 (=prop dirlling)
이라고 합니다.
<Parent >
<ChildrenA props={date}>
<ChildrenB props={date}>
<ChildrenC props={date}>
<ChildrenD props={date}>
<ChildrenE props={date}/>
</ ChildrenD>
</ ChildrenC>
</ ChildrenB>
</ChildrenA>
<Parent/>
prop drilling
은 리액트 애플리케이션에 그다지 좋은 영향을 주진 않는데요, 그 이유는 해당 데이터를 사용하지 않는 컴포넌트에서도 오로지 값을 전달하기 위해 props
를 받아야 하고, 이를 받는 컴포넌트에서도 props
가 제대로 전달되었는지 확인해야 하는 등 번거로운 작업을 해줘야하기 때문입니다. 이런 prop drilling
을 극복하기 위해 등장한 개념이 context입니다.
context를 사용하면 명시적으로 props를 전달해주지 않아도 자식 컴포넌트 모두가 부모의 데이터를 자유자재로 사용할 수 있게 됩니다. useContext는 이러한 context를 함수 컴포넌트에서 사용할 수 있도록 해주는 React 훅입니다.
createContext
로 컨텍스트를 생성한 후 초기값을 설정해줍니다. 그런 뒤, 하위 컴포넌트 모든 곳에서 값을 참조할 수 있도록 <Context.Provider />
컴포넌트로 하위 컴포넌트들을 감싸주고 value
를 사용해 값을 전달합니다.
import React, { createContext, useState, ReactNode } from 'react';
export const CartContext = createContext<any>(null);
const App = () => {
const [cart, setCart] = useState<string[]>([]);
const addToCart = (item: string) => {
setCart((prevCart) => [...prevCart, item]);
};
const value = {
cart,
addToCart,
};
return (
<CartContext.Provider value={value}>
<div>
<Header />
<ProductList />
<Cart />
</div>
</CartContext.Provider>
);
};
export default App;
이제 useContext
를 이용해서 <Context.Provider/>
에서 제공하는 상태 값을 가져와서 사용해주면 됩니다. 확실히 useContext를 사용해주니 전보다 코드가 굉장히 깔끔해졌죠?
import React, { useContext } from 'react';
import { CartContext } from './App';
const Cart = () => {
const { cart } = useContext(CartContext);
return (
<div>
<h2>Shopping Cart</h2>
<ul>
{cart.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
export default Cart;
import React, { useContext } from 'react';
import { CartContext } from './App';
const ProductList = () => {
const { addToCart } = useContext(CartContext);
const products = ['Apple', 'Banana', 'Orange', 'Mango'];
return (
<div>
<h2>Products</h2>
<ul>
{products.map((product) => (
<li key={product}>
{product}
<button onClick={() => addToCart(product)}>Add to Cart</button>
</li>
))}
</ul>
</div>
);
};
export default ProductList;
그러나, useContext
를 사용할 때 몇 가지 주의해야 할 점이 있습니다.
일반적으로 useContext
를 사용하게 되면 해당 컴포넌트는 Provider
의 하위에 있어야 합니다. 이는 즉, useContext
를 내부에서 사용하고 있는 함수 컴포넌트는 Provider
에 의존하고 있는 관계로 일반 함수 컴포넌트에 비해 재사용하기 어렵다고 볼 수 있습니다. 그렇다고 모든 컨텍스트를 프로젝트 루트 컴포넌트에 등록해버리면 필요치 않은 리소스 낭비가 발생할 수 있기 때문에 가능하면 좁은 범위에서 꼭 필요할 때 사용하는 것이 좋습니다.
import React from 'react';
const Header = () => {
console.log('Header rendered');
return (
<header>
<h1>아무개님의 장바구니</h1>
</header>
);
};
export default Header;
App.tsx
에서 Context
를 생성하여 실행시켜보면, useContext
를 사용하고 있지 않는 <Header/>
컴포넌트도 리렌더링되는 것을 볼 수 있습니다. Context
를 컴포넌트 내부에서 생성하면, 해당 컴포넌트가 리렌더링될 때마다 새로운 Context가 생성됨에 따라 새로운 참조 값이 생성되어 자식 컴포넌트들도 리렌더링 되기 때문입니다. 그래서 <Header/>
컴포넌트를 React.memo
로 메모이제이션해주거나, Context
를 외부에서 생성하여 불필요한 리렌더링을 막아야 합니다. 외부에서 생성해주면 동일한 참조 값을 공유해 Context
가 변경될 때 useContext
를 사용하는 컴포넌트만 리렌더링시킬 수 있습니다.
useReducer
는 useState
와 비슷하나, 좀 더 복잡한 상태값을 사전에 정의해놓고 특정 시나리오(=action)에 따라 관리할 수 있습니다.
useReducer
는 두 개의 요소를 배열에 담아 반환하는데, 첫 번째는 useReducer
가 가지고 있는 값(=state)이고, 두 번째는 state를 업데이트 하는 함수인 dispatcher
입니다. 여기서 dispatcher
는 state를 변경할 수 있는 action
을 넘겨줍니다.
useReducer
의 인수로는 최소 2개에서 3개가 필요합니다. 첫 번째는 useReducer의 기본 action을 정의한 함수인 reducer
이고, 두 번째 인수는 userReducer의 초깃값인 initialState
입니다. 마지막 인수는 선택인데요, 초깃값을 지연해서 생성시키고 싶을 때 사용하는 함수인 init
입니다.
useContext
예시에서 useState
로 상태를 업데이트해줬던 부분을 useReducer
를 사용해서 변경해보겠습니다.
interface CartItem {
id: string;
name: string;
}
interface State {
cart: CartItem[];
}
type Action =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string };
const initialState: State = {
cart: [],
};
const cartReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_ITEM':
return { cart: [...state.cart, action.payload] };
case 'REMOVE_ITEM':
return { cart: state.cart.filter((item) => item.id !== action.payload) };
default:
return state;
}
};
기존에 useState
로 관리했던 상태와 업데이트 함수를 useReducer
로 변경하고, 해당 상태와 업데이트 함수인 dispatcher
를 context로 넘겨 모든 자식 컴포넌트에서 참조할 수 있도록 해줍니다.
export const CartContext = createContext<
{ state: State; dispatch: React.Dispatch<React.SetStateAction> } | undefined
>(undefined);
export const CartProvider = ({ children }: { children: React.ReactNode }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
};
'담기' 버튼을 클릭하면 'ADD_ITEM' 액션이 디스패치되는데, 이때 이 예제처럼 payload를 사용해서 액션에 추가적인 값을 더해줄 수도 있습니다.
const products = [
{ id: '1', name: '바나나' },
{ id: '2', name: '파인애플' },
{ id: '3', name: '오렌지' },
];
const ProductList: React.FC = () => {
const { dispatch } = useContext(CartContext);
const handleAddToCart = (product: (typeof products)[0]) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
return (
<div>
<h2>과일 목록</h2>
<ul>
{products.map((product) => (
<li key={product.id}>
<p>{product.name}</p>
<button onClick={() => handleAddToCart(product)}>담기</button>
</li>
))}
</ul>
</div>
);
};
export default ProductList;
이렇게 Context API와 useReducer에 대해 알아보았는데요, 두 기술을 접목해서 잘 활용하면 Redux처럼 (실제로 Redux 또한 useReducer를 기반으로 한다고 하죠.) 중앙 집중형 상태 관리로 더욱 쉽게 상태를 이용하고 변경할 수 있는 것 같습니다. 물론, 렌더링 최적화가 잘 이루어지고 있는지 적용하면서 틈틈이 확인하는 작업도 해야하겠지만요🙂