Context 란?
context를 이용하면 단계마다 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있습니다.
라고 React의 공식문서에서 소개하고 있습니다.
언제 context를 써야 할까
리액트 공식문서는 다음과 같이 설명하고 있습니다.
context는 React 컴포넌트 트리 안에서 전역적(global)이라고 볼 수 있는 데이터를 공유할 수 있도록 고안된 방법입니다.
하지만 반드시 전역적인 데이터만을 위해서 사용하지 않아도 됩니다. 필요한 부분에만 Context Provider를 감싸주면 필요한 부분에서만 사용할 수 있습니다.
위 사진과 같이 React에서는 기본적으로 하위 컴포넌트에서 어떤 정보를 사용하고 싶으면 계속 Props로 넣어주어야 하는(이것을 Prop Drilling이라고 합니다.) 불편함이 있는데, 이를 해결하기 위한 방법이라고 생각하시면 될 것 같습니다.
다음과 같이 간단한 Todo List 예시를 들어서 설명하겠습니다. toDo, inProgress, done 세개의 리스트가 있고, 각각의 리스트에 항목을 추가하고 삭제할 수 있습니다.
현재 상태의 코드는 다음과 같습니다. 각각의 컴포넌트에서 따로따로 상태를 관리하고 있고, 기본적으로 추가, 삭제 기능이 구현된 상태입니다. (간단한 예시용 코드이므로 코드 가독성을 위해 모든 css는 삭제했습니다.)
import React from "react";
import ListComponent from "../components/ListComponent";
const Index: React.FC = () => {
return (
<div>
<h1>TODO</h1>
<div>
<ListComponent type="toDo" />
<ListComponent type="inProgress" />
<ListComponent type="done" />
</div>
</div>
);
};
export default Index;
TypeScript
import React, { useState } from "react";
import { Button, Form, Input, List } from "antd";
import { Store } from "antd/lib/form/interface";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface Props {
type: "toDo" | "inProgress" | "done";
}
const ListComponent: React.FC<Props> = ({ type }) => {
const [lists, setLists] = useState<string[]>([]);
const [form] = Form.useForm();
const handleFinish = async (values: Store) => {
if (!values.listInput) return;
setLists([...lists, values.listInput]);
form.setFields([{ name: "listInput", value: "" }]);
};
const deleteListItem = (index: number) => {
setLists([...lists.slice(0, index), ...lists.slice(index + 1)]);
};
return (
<div>
<h1>{type}</h1>
<List
dataSource={lists}
renderItem={(item, i) => (
<List.Item key={`${type}${i}`}>
<span>{item}</span>
<span onClick={() => deleteListItem(i)}>
❌
</span>
</List.Item>
)}
/>
<Form form={form} onFinish={handleFinish} style={{ display: "flex" }}>
<Form.Item name="listInput" initialValue="">
<Input />
</Form.Item>
<Form.Item noStyle>
<Button htmlType="submit" type="primary">
추가하기
</Button>
</Form.Item>
</Form>
</div>
);
};
export default ListComponent;
TypeScript
현재 상태에서, toDo에 있는 항목을, inProgress로, inProgress에 있는 항목을 toDo나 done으로, done에 있는 항목을 inProgress로 옮기는 기능을 추가하고 싶지만, 현재 상태에서는 state관리를 각 컴포넌트에서 하고 있기 때문에 불가능합니다.
간단하게 부모 컴포넌트에서 세 개의 state를 모두 선언한 뒤 ListComponent가 state와 setState를 받을 수 있게 하는 방법이 있을 수도 있습니다. 인자를 lists와 setLists로 받으면 기존 로직을 수정할 필요도 없습니다.
그렇게 수정한 코드는 다음과 같습니다.
import React, { useState } from "react";
import ListComponent from "../components/ListComponent";
const Index: React.FC = () => {
const [todo, setTodo] = useState<string[]>([]);
const [inProgress, setInProgress] = useState<string[]>([]);
const [done, setDone] = useState<string[]>([]);
return (
<di>
<h1>TODO</h1>
<div>
<ListComponent
type="toDo"
lists={todo}
setLists={setTodo}
rightLists={inProgress}
setRightLists={setInProgress}
/>
<ListComponent
type="inProgress"
lists={inProgress}
setLists={setInProgress}
leftLists={todo}
setLeftLists={setTodo}
rightLists={done}
setRightLists={setDone}
/>
<ListComponent
type="done"
lists={done}
setLists={setDone}
leftLists={inProgress}
setLeftLists={setInProgress}
/>
</div>
</div>
);
};
export default Index;
TypeScript
import React from "react";
import { Button, Form, Input, List } from "antd";
import { Store } from "antd/lib/form/interface";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface Props {
type: "toDo" | "inProgress" | "done";
lists: string[];
setLists: React.Dispatch<React.SetStateAction<string[]>>;
leftLists?: string[];
setLeftLists?: React.Dispatch<React.SetStateAction<string[]>>;
rightLists?: string[];
setRightLists?: React.Dispatch<React.SetStateAction<string[]>>;
}
const ListComponent: React.FC<Props> = ({
type,
lists,
setLists,
leftLists,
setLeftLists,
rightLists,
setRightLists,
}) => {
const [form] = Form.useForm();
const handleFinish = async (values: Store) => {
if (!values.listInput) return;
setLists([...lists, values.listInput]);
form.setFields([{ name: "listInput", value: "" }]);
};
const deleteTodo = (index: number) => {
setLists([...lists.slice(0, index), ...lists.slice(index + 1)]);
};
const toLeft = (index: number) => {
if (leftLists && setLeftLists) {
setLeftLists([...leftLists, lists[index]]);
deleteTodo(index);
}
};
const toRight = (index: number) => {
if (rightLists && setRightLists) {
setRightLists([...rightLists, lists[index]]);
deleteTodo(index);
}
};
return (
<div>
<h1>{type}</h1>
<List
dataSource={lists}
renderItem={(item, i) => (
<List.Item key={`${type}${i}`}>
<span>{item}</span>
<span
onClick={() => deleteTodo(i)}
>
❌
</span>
{leftLists && (
<span onClick={() => toLeft(i)}>
<ArrowLeftOutlined />
</span>
)}
{rightLists && (
<span onClick={() => toRight(i)}>
<ArrowRightOutlined />
</span>
)}
</List.Item>
)}
/>
// 바뀐 부분이 없어 생략하겠습니다.
<Form form={form} onFinish={handleFinish}>
...
</Form>
</div>
);
};
export default ListComponent;
TypeScript
잘 동작하네요
하지만 이렇게 다른 상태의 값과 수정하는 함수도 Props로 받아야 해서 굉장히 길고 복잡해집니다. 만약 ListComponent에서 바로 사용하지 않고 한두 단계 더 아래로 내려가서 사용하게 되어있다면 계속 Props로 넣어줘야 해서 더욱더 비효율적이겠죠.
그래서 이러한 문제를 사용하기 위해서 Context API를 사용해 보도록 하겠습니다.
별도의 파일에서 context 관련 함수들을 모두 정의합니다. 물론 꼭 이렇게 할 필요는 없습니다.
import React, { createContext, useContext, useMemo, useReducer } from "react";
// 이곳저곳에서 쓰이던 listsType을 이곳에서 정해서 통일해서 쓰겠습니다.
export type listsType = "toDo" | "inProgress" | "done";
// Context API에서 쓸 state 타입과 value 타입을 선언해 줍니다.
interface listsContextStateType {
toDo: string[];
inProgress: string[];
done: string[];
}
interface listsContextValueType {
state: listsContextStateType;
dispatch: React.Dispatch<{
type: string;
key: listsType;
value: string | number;
}>;
}
// context를 생성합니다.
// 이렇게 하면 defaultValue를 넣지 않았을때 TypeScript에서 발생하는 에러를 막을수 있습니다.
export const ToDoListsContext = createContext<
listsContextValueType | undefined
>(undefined);
// reducer도 정의해 줍니다.
export const toDoListsReducer = (
state: listsContextStateType,
action: {
type: string;
key: listsType;
value: string | number;
}
) => {
switch (action.type) {
case "add":
return { ...state, [action.key]: [...state[action.key], action.value] };
case "delete":
if (typeof action.value !== "number") return { ...state };
const lists = state[action.key];
return {
...state,
[action.key]: [
...lists.slice(0, action.value),
...lists.slice(action.value + 1),
],
};
default:
throw new Error();
}
};
// 사용하기 쉽게 Provider를 감싸는 함수를 만들어 줍니다.
// Functional component에서 children 타입을 선언해 주어야 하는건 react 18버전부터 입니다.
export const ToDoListsProvider: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const [state, dispatch] = useReducer(toDoListsReducer, {
toDo: [],
inProgress: [],
done: [],
});
// context provider의 value가 바뀌면 context를 구독하는 모든 컴포넌트들이
// 다시 rendering 되므로 useMemo를 사용해줍니다.
const contextValue = useMemo(() => ({ state, dispatch }), [state]);
return (
<ToDoListsContext.Provider value={contextValue}>
{children}
</ToDoListsContext.Provider>
);
};
// context를 더 쉽게 사용할 수 있도록 감싸주고, provider가 없는 경우 에러를 표시해 줍니다.
export const useToDoLists = () => {
const context = useContext(ToDoListsContext);
if (context === undefined) {
throw new Error("useToDoLists must be used within a ToDoListsContext");
}
return context;
};
TypeScript
부모 컴포넌트에서 필요 없어진 state를 삭제하고 Provider로 감싸줍니다. ListComponent에도 state를 넘겨줄 필요가 없어졌습니다.
import React from "react";
import ListComponent from "../components/ListComponent";
import { ToDoListsProvider } from "../hooks/useTodoLists";
const Index: React.FC = () => {
return (
<ToDoListsProvider>
<div>
<h1>TODO</h1>
<div>
<ListComponent type="toDo" />
<ListComponent type="inProgress" />
<ListComponent type="done" />
</div>
</div>
</ToDoListsProvider>
);
};
export default Index;
TypeScript
ListComponent에서 정의해둔 useToDoLists 를 사용해서 context API에 접근해서 state와 dispatch를 사용하도록 리팩토링했습니다. 불필요한 Props들이 많이 줄어들었습니다.
import React from "react";
import { Button, Form, Input, List } from "antd";
import { Store } from "antd/lib/form/interface";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
import { useToDoLists, listsType } from "../hooks/useTodoLists";
interface Props {
type: listsType;
}
const ListComponent: React.FC<Props> = ({ type }) => {
const [form] = Form.useForm();
const { state, dispatch } = useToDoLists();
// left와 right를 넣어줄 필요 없이 type에따라 결정
const leftKey: listsType | undefined =
type === "done" ? "inProgress" : type === "inProgress" ? "toDo" : undefined;
const rightKey: listsType | undefined =
type === "done" ? undefined : type === "inProgress" ? "done" : "inProgress";
const lists = state[type]
// toLeft 와 toRight함수를 하나의 함수로 사용할수있게 리팩토링
const moveTodo = (key: listsType, index: number) => {
dispatch({ type: "add", key, value: lists[index] });
dispatch({ type: "delete", key: type, value: index });
};
const handleFinish = async (values: Store) => {
if (!values.listInput) return;
dispatch({ type: "add", key: type, value:values.listInput });
form.setFields([{ name: "listInput", value: "" }]);
};
return (
<div>
<h1>{type}</h1>
<List
dataSource={lists}
renderItem={(item, i) => (
<List.Item key={`${type}${i}`}>
<span>{item}</span>
<span onClick={() => dispatch({ type: "delete", key: type, value: i })}>
❌
</span>
{leftKey && (
<span onClick={() => moveTodo(leftKey, i)}>
<ArrowLeftOutlined />
</span>
)}
{rightKey && (
<span onClick={() => moveTodo(rightKey, i)}>
<ArrowRightOutlined />
</span>
)}
</List.Item>
)}
/>
<Form form={form} onFinish={handleFinish}>
...
</Form>
</div>
);
};
export default ListComponent;
TypeScript
이런 식으로 하위 컴포넌트로 Props를 넘겨줄 필요 없이 어디에서나 필요한 state에 접근하고, state를 수정할 수 있습니다.
Re-rendering이 걱정된다면?
<MyContext.Provider value={/* 어떤 값 */}>
TypeScript
Context Provider 컴포넌트는 value prop을 받아서 이 값을 하위에 있는 컴포넌트에게 전달합니다. 값을 전달받을 수 있는 컴포넌트의 수에 제한은 없습니다. Provider 하위에 또 다른 Provider를 배치하는 것도 가능하며, 이 경우 하위 Provider의 값이 우선시됩니다. Provider 하위에서 context를 구독하는 모든 컴포넌트는 Provider의 value prop가 바뀔 때마다 다시 렌더링 됩니다. 그리고 context 값의 바뀌었는지 여부는 Object.is 와 동일한 알고리즘을 사용해 이전 값과 새로운 값을 비교해 측정됩니다.
이를 위해서 앞서 작성한 코드에서는 contextValue를 useMemo로 감싸주었습니다. 이렇게 하면 매번 Provider의 하위 컴포넌트들이 전부 다시 rendering 하는 것을 줄일 수 있습니다.
// 사용하기 쉽게 Provider를 감싸는 함수를 만들어 줍니다.
// Functional component에서 children 타입을 선언해 주어야 하는건 react 18버전부터 입니다.
export const ToDoListsProvider: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const [state, dispatch] = useReducer(toDoListsReducer, {
toDo: [],
inProgress: [],
done: [],
});
// context provider의 value가 바뀌면 context를 구독하는 모든 컴포넌트들이
// 다시 rendering 되므로 useMemo를 사용해줍니다.
const contextValue = useMemo(() => ({ state, dispatch }), [state]);
return (
<ToDoListsContext.Provider value={contextValue}>
{children}
</ToDoListsContext.Provider>
);
};
TypeScript
추가로 React.memo를 사용해서 하위 컴포넌트를 감싸주는 방법이 있습니다.
리렌더링을 피하고 싶은 Context를 구독하지 않은 하위 컴포넌트 ShouldNotReRender 가 있을 때,
import React from "react";
const ShouldNotReRender = ()=>{
console.log("rerendered!");
return (
<div>
ShouldNotReRender
</div>
);
});
export default ShouldNotReRender;
TypeScript
이 컴포넌트를 ListComponent에 넣어주면. 다음과 같이 동작합니다.
리렌더링시 ShouldNotReRender 컴포넌트에서는 Context에 접근하고 있지 않는데도 해당 부분이 highlight되는것도 확인할 수 있고, console.log도 계속해서 생깁니다.
이를 해결하기 위해서 React.memo를 사용해서 해당 컴포넌트를 감싸주었습니다.
import React from "react";
const ShouldNotReRender = React.memo(function ShouldNotReRender() {
console.log("rerendered!");
return (
<div>
ShouldNotReRender
</div>
);
});
export default ShouldNotReRender;
TypeScript
이제는 console에도 더이상 추가되지 않고, ShouldNotReRender 부분도 highlight 되지 않습니다.
이렇게 다시 렌더링 되는 부분을 제거하고 최적화할 수 있었습니다. 하지만 정말 성능저하가 있어서 최적화 하는 것이 아니라 최적화를 위한 최적화는 피하는 것이 좋겠습니다.
이렇게 React의 Context를 사용하는 방법을 간단한 예시와 함께 알아보았습니다. React 안에서 전역적인 데이터를 공유할 수 있도록 React에 기본적으로 포함되어 있지만, Redux, Mobx, Recoil 등등 다양한 전역 상태 관리 라이브러리들이 더욱 많이 있고, 아주 많이 사용되고 있어서 Context를 사용할 일이 그리 많지 않은 것은 사실입니다. (더 사용하기 편한것 같기도…)
하지만 소규모 프로젝트거나 별도의 라이브러리 설치 없이 간단한 기능을 필요로 한다면 사용해 보아도 좋을 것 같습니다.
참고자료
저자소개
나건일은 유하에서 프론트엔드 개발자로 일하고 있습니다. Youha의 서비스 웹페이지뿐 아니라 내부의 Back office들을 개발하는데 열과 성을 다하고 있습니다. Youha의 채팅기능을 비롯한 많은 새로운 기능들을 개발하는데 더 효과적으로 개발하는 것을 고민하고 좋은 방법론들을 실천하고 있습니다. 특별히 여러 개발자들이 그와 함께 일하고 싶어할만큼 친화력이 좋습니다.