안녕하세요, 오늘은 context API를 이용해 전역 상태(global state)를 생성하고 사용해보겠습니다.
zustand나 redux와 같은 상태 관리 툴을 사용하지 않아도,
리액트에서 기본으로 제공하는 context API를 사용해 전역 상태 관리가 가능합니다.
리액트 컴포넌트들 사이에 상태(state)를 공유해야 하는 상황은 정말 자주 발생합니다.
정의된 컴포넌트 밖에서도 사용되는 state를 전역상태라고 합니다.
컴포넌트들 사이에 상태(state)를 공유해야 하는 상황의 예를 들어볼까요?
완료 버튼을 누르면 화면에 뜨는 "남은 일의 개수: n" 에서 n값이 하나씩 줄어드는 todo리스트를 구현한다고 해봅시다.
친구와 경쟁하기 기능도 구현하려 하는데, 친구의 남은 할 일 개수와 나의 남은 할 일 개수를 비교하고 싶습니다. 누가 더 게으른 하루를 보내고 있는지 확인하고 싶거든요.
그럼,
나의 할 일을 관리하는 컴포넌트와, 친구의 할 일을 관리하는 컴포넌트 각각의
"남은 할 일 개수" 를 나타내는 state를 서로 참조하여 비교할 수 있어야겠네요.
리액트의 상태(state)를 전역적으로 사용하는 대표적인 방법으로 state를 prop으로 전달할 수 있는데요, 이는 prop drilling이 발생하는 상황에선 그리 효율적인 방법이 아닐 수 있었습니다.
그래서 오늘은, prop drilling문제점이 발생하는 상황에서 성능 개선을 위해 도입을 고려해볼 수 있는 context API에 대해 알아보겠습니다.
context API는 React의 상태를 필요한 어디서든 사용할 수 있도록 하는 개념입니다.
어디서든 사용할 수 있도록 만들고자 하는 전역 상태는 컨텍스트 객체의 Provier라는 컴포넌트에 작성해줘야 합니다.
주로, src폴더 내에 providers 또는 contexts라는 폴더를 만들어 그 안에 작성합니다.
전역상태를 생성하는 컴포넌트를 Provider라고 부릅니다.
이제 Context Provider를 구현해보겠습니다.
먼저, 프로젝트의 폴더구조를 다음과 같이 만들어주겠습니다.
providers폴더 내의 LeftTodoProvider.jsx 파일에서 전역 상태를 생성하고,
추후 이를 TodoLists 컴포넌트에서 사용하겠습니다.
지금은 투두리스트 로직을 공부하려는 것이 아닌,
전역 상태 관리를 살펴보고자 하는 것이니, 유저한테 할 일 항목을 입력받도록 구현하는 대신,
제가 미리 할 일을 배열에 만들어두고, map함수를 돌려 html요소로 렌더링 하겠습니다.
아래는 TodoLists 컴포넌트입니다.
import { useState } from "react";
import styled from "styled-components";
const Wrapper = styled.div`
border: 2px solid black;
margin: 5px;
width: 50%;
`;
const TodoLists = () => {
const todoLists = ["밥 먹기", "잠 자기", "즐겁게 놀기"];
const [todoItem, setTodoItem] = useState(todoLists);
const [leftTodonum, setLeftTodoNum] = useState(todoItem.length);
const handleCompleteBtnClick = () => {
setLeftTodoNum((prev) => prev - 1);
};
return (
<>
{todoItem.map((item, idx) => (
<Wrapper id={idx}>
<h3>{item}</h3>
<button onClick={handleCompleteBtnClick}>완료!</button>
</Wrapper>
))}
</>
);
};
export default TodoLists;
export { TodoLists };
styled components를 사용하였는데, 해당 내용을 모르시는 분들은 제 포스팅 중 css-in-js글을 참고해주시기 바랍니다!
providers 폴더의 LeftTodoProvider.jsx를 작성해봅시다.
아래는 완성된 전체 코드입니다.
import { createContext, useContext, useState } from "react";
const LeftTodoContext = createContext();
console.log(LeftTodoContext);
const defaultTodoItems = ["밥 먹기", "잠 자기", "즐겁게 놀기"];
const LeftTodoProvider = ({ children }) => {
let [todoItems, setTodoItems] = useState(defaultTodoItems);
const [todoItemsCount, setTodoItemsCount] = useState(defaultTodoItems.length);
//완료 버튼을 누르면 남은 일의 개수를 줄여주는 함수
const handleComplete = () => {
setTodoItemsCount(todoItemsCount - 1); // 남은 아이템 개수 감소
};
return (
<LeftTodoContext.Provider
value={{ todoItems, todoItemsCount, handleComplete }}
>
{children}
</LeftTodoContext.Provider>
);
};
export const useLeftTodoContext = () => {
return useContext(LeftTodoContext);
};
export default LeftTodoProvider;
이제, 차근 차근 한 줄씩 알아봅시다.
const LeftTodoContext = createContext();
전역 상태를 내보내고 받아 사용하기 위해선, createContext() 함수를 이용해 컨텍스트 객체를 생성해야 합니다.
컨텍스트 객체란 무엇이고, 어떻게 생겼을까요?
제가 컨텍스트 API를 처음 공부할 때,
거의 모든 블로그와 유튜브 튜토리얼에서 useContext() 함수를 이용해 컨텍스트 객체를 생성하면 된다는 식으로 퉁치고 넘어가곤 했습니다.
근데 전 컨텍스트 객체 자체가 무엇이고, 내부적으로 어떻게 생겼는지 너무 궁금하고 답답하더군요. 그래서, 제 포스팅에선 컨텍스트 객체 자체에 잠시 집중해보겠습니다.
console.log를 이용해 콘솔에 LeftTodoContext를 찍어보면 다음과 같은 결과가 출력됩니다.
컨텍스트 객체는 일종의 자바스크립트 객체로서, 구성 요소로 Provider, Consumer, _currentvalue등을 갖습니다.
여기서, Provider와 Consumer는 리액트 컴포넌트입니다.
Provider는 상태를 타 컴포넌트가 사용할 수 있도록 제공해주고,
Consumer는 상태를 소비(전역 상태를 사용 하는 것을 소비라고도 부릅니다)하는 컴포넌트입니다.
_currentvalue에는 createContext() 함수의 인자로 전달된 값이 저장됩니다.
저희는 비워두었기에 undefined가 저장되었음을 알 수 있습니다.
즉, 컨텍스트 객체는 리액트 컴포넌트를 구성 요소로 갖는 자바스크립트 객체 입니다.
생성된 컨텍스트 객체 LeftTodoContext의 구성요소 Provider가 컴포넌트이니,
LeftTodoContext.Provider 는 컴포넌트입니다.
이 컴포넌트는 prop을 받을 수 있는데, value라는 이름으로 전달해줍니다.
value에 전달하는 값이 바로 useContext()를 사용하여 전역 상태를 사용하고자 하는 지점에서 접근할 수 있는 값들입니다.
아래의 코드를 볼까요?
<LeftTodoContext.Provider value={{ todoItems, todoItemsCount, handleComplete }}>
{children}
</LeftTodoContext.Provider>
다음과 같이, LeftTodoContext.Provider컴포넌트의 value prop에
todoItems, todoItemsCount, handleComplete를 전달했네요.
value에 {}가 아닌 {{}}를 사용한 이유도 짚고 넘어가볼게요.
처음 중괄호 쌍은 JSX에서 자바스크립트 코드를 삽입하기 위함이고,
두 번째 중괄호 쌍은 정말 순수히 외부로 보내줄 전역 상태 값들을
자바스크립트 객체로 넣어주기 위함입니다.
값들을 객체로 전달하고, use하는 지점에서 객체 구조분해 할당으로 값을 뽑아 사용하는 방식입니다.
이제, useContext() 훅을 이용하면 이 컴포넌트 밖에서도 어디서든, 얼마든 이 값들을 사용할 수 있습니다.
export const useLeftTodoContext = () => {
return useContext(LeftTodoContext);
};
export default LeftTodoProvider;
위와 같이 useLeftTodoContext이라는 커스텀 훅을 제작했습니다.
이 훅을 이용하면, LeftTodoContext의 상태값에 접근할 수 있겠네요.
사실, 커스텀 훅을 제작하지 않고, 상태에 접근하려는 컴포넌트에서 바로 useContext(LeftTodoContext) 로 전역상태를 소비해도 문법상으론 오류가 없습니다.
하지만, 대부분의 프로젝트에서 전역 상태는 여러 곳에서 사용될 것이고,
쓰는 곳마다 동일한 코드를 계속 반복해야 합니다.
중복 코드가 발생하는 것이죠!
더 큰 문제는, 해당 부분을 수정하고자 한다면, 호출된 모든 부분을 찾아 수정해야 한다는 것입니다.
이를 방지하고자 커스텀 훅을 제작하여 사용합니다.
전역 상태 관리를 위해선,
다음과 같이 전역 상태를 사용하고자 하는 모든 컴포넌트나 페이지를
Provider컴포넌트로 감싸주어야 합니다.
App.js의 파일을 아래와 같이 작성해주었습니다.
import "./App.css";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Home } from "./pages/main";
import LeftTodoProvider from "./providers/LeftTodoProvider";
function App() {
return (
<LeftTodoProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
</LeftTodoProvider>
);
}
export default App;
저는 Home 페이지를 자식 컴포넌트로 주고 있습니다.
참고로, Home 페이지는 다음과 같이 작성했습니다.
pages/main.jsx파일에 작성했습니다
import { TodoLists } from "../components/TodoLists.jsx";
const Home = () => {
return (
<>
<TodoLists />
</>
);
};
export default Home;
export { Home };
context API로 전역 상태로 만들 "할 일 목록" 은 TodoLists컴포넌트가 아닌 Provider컴포넌트에 존재해야 한다는 것을 이해할 수 있습니다.
저희는 이미 Provider에 할 일 목록을 적어주었습니다.
그러기에, TodoLists컴포넌트에서 할 일 목록을 나타내는 html요소를 렌더링 해줄 때,
state를 컴포넌트 안에 작성해주던 기존 방식에서,
useContext()를 이용해 전역상태를 받아 사용하도록 구현하겠습니다.
아래는 완성된 코드입니다.
import styled from "styled-components";
import { useLeftTodoContext } from "../providers/LeftTodoProvider";
const Wrapper = styled.div`
border: 2px solid black;
margin: 5px;
width: 50%;
`;
const TodoLists = () => {
let { todoItems, todoItemsCount, handleComplete } = useLeftTodoContext();
const handleCompleteBtnClick = () => {
handleComplete();
};
return (
<>
{todoItems.map((item, idx) => (
<Wrapper key={idx}>
<h3>{item}</h3>
<button onClick={handleCompleteBtnClick}>완료!</button>
</Wrapper>
))}
<h1>남은 일의 개수: {todoItemsCount}</h1>
</>
);
};
export default TodoLists;
export { TodoLists };
핵심인 부분만 보면,
import { useLeftTodoContext } from "../providers/LeftTodoProvider";
useContext()를 실행하도록 구현한 커스텀 훅을 사용하기 위해 불러와주고,
아래와 같이 구조 분해 할당 문법을 사용해 provider에서 value로 제공한 값들을 받아옵니다.
let { todoItems, todoItemsCount, handleComplete } = useLeftTodoContext();
todoItems, todoItemsCount는 provider컴포넌트에 작성해둔 state, handleComplete은 함수로서, 이렇게 useContext혹은 커스텀 훅을 사용해 어디서든 구조 분해 할당을 통해 상태에 접근할 수 있습니다.
<button onClick={handleCompleteBtnClick}>완료!</button>
완료 버튼의 클릭 이벤트 핸들러도, provider에서 value로 전달해준 함수를 실행하도록 해주면 되겠네요.
const handleCompleteBtnClick = () => {
handleComplete();
};
이제 이 버튼이 클릭되면, provider에 적어둔 handleComplete함수가 실행됩니다.
타 컴포넌트의 기능이고, 타 컴포넌트의 숫자 상태를 조작하는 로직이 담겨있음에도,
전역 상태로 추출한 값들이기에 어느 컴포넌트에서든 해당 함수가 동작하는 것입니다.
아래는 전체적인 TodoLists컴포넌트의 코드입니다.
const TodoLists = () => {
let { todoItems, todoItemsCount, handleComplete } = useLeftTodoContext();
const handleCompleteBtnClick = () => {
handleComplete();
};
return (
<>
{todoItems.map((item, idx) => (
<Wrapper key={idx}>
<h3>{item}</h3>
<button onClick={handleCompleteBtnClick}>완료!</button>
</Wrapper>
))}
<h1>남은 일의 개수: {todoItemsCount}</h1>
</>
);
};
이제, 친구의 컴포넌트에서 내 컴포넌트의 값을 어떻게 가져갈지,
이해가 되시죠?
이해가 되신다면 전역 상태를 충분히 이해하셨다고 봐도 무방합니다!
Context API를 사용하면 Provider에 작성한 모든 상태와 함수들은 어디서든 useContext() 로 사용이 가능합니다.
createContext()로 컨텍스트 객체를 생성하고,
그 객체의 구성 요소인 Provider컴포넌트의 value속성에 객체 형태로 state와 함수들을 내보낼 수 있습니다
useContext() 혹은 그 로직을 포함하는 커스텀 훅을 이용하는 모든 지점에서, ES6+ 의 구조분해 할당을 이용해 provider컴포넌트의 value prop으로 전달한 모든 state와 함수들을 사용할 수 있습니다.