React
로 애플리케이션을 만들다 보면 "전역 상태 관리 라이브러리가 필요하겠는걸?" 하는 상황을 자주 마주하게 됩니다. 이는 React
가 단방향으로 데이터가 흐르기에 상위 컴포넌트로 상태를 전달하기 까다롭기도 하고 규모가 커지는 애플리케이션일수록 컴포넌트가 많아져 상태를 개별적으로 관리하기에는 힘들어지기 때문입니다.
위와 같은 상황에서 저는 Redux
, Recoil
과 같은 전역 상태 관리 라이브러리를 설치하여 사용했습니다. 라이브러리는 무척 편하고 효율도 좋아 만족도가 높았지만, 간단한 프로젝트에서 몇 개 안 되는 상태를 전역으로 관리하기 위해 라이브러리를 무조건 설치해야만 하는 상황이 오히려 비효율적이라 생각이 들어 React
에서 자체적으로 제공되는 기능으로 전역 상태가 관리할 수 있는 방식을 알아보게 되었습니다.
React
에서 제공하는 Context API
, useReducer
를 사용하여 전역 상태를 관리할 수 있습니다. 두 가지 기능 모두 자주 사용해 보지 않아 이번 기회에 확실히 알아가며 배우고, 전역 상태 관리까지 구현해 보았습니다.
Context API
를 이용하면 단계마다 일일이 props
를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있습니다. 일반적인 React
애플리케이션에서 데이터는 위에서 아래로 (즉, 부모로부터 자식에게) props
를 통해 전달되지만, 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 props
의 경우 (예를 들면 UI 테마) 이 과정이 번거로울 수 있습니다. 즉 Context API
를 이용하면 prop drilling
을 방지하며, 컴포넌트들이 상태를 공유할 수 있습니다.
prop drilling :
props
를 오로지 하위 컴포넌트로 전달하는 용도로만 쓰이는 컴포넌트들을 거치면서 React Component 트리의 한 부분에서 다른 부분으로 데이터를 전달하는 과정입니다.
createContext
를 통해 context
를 생성할 수 있습니다. defaultValue
가 없으면 null
을 지정할 수 있습니다.
const SomeContext = createContext(defaultValue)
이렇게 생성된 context
를 이용하여 SomeContext.Provider
로 컨텍스트 값을 지정할 수 있고, useContext(SomeContext)
를 통해 이를 읽을 수 있습니다.
구성 요소의 컨텍스트를 읽고 구독할 수 있게 해주는 React Hook입니다 .
const value = useContext(SomeContext)
SomeContext.Provider
를 통해 구성 요소를 컨텍스트 공급자로 래핑하여 내부의 모든 구성 요소에 대해 컨텍스트 값을 지정합니다. SomeContext.Provider
의 value값에 컨텍스트 값을 지정할 수 있습니다.
아래는 theme값에 따라 변경되는 예제입니다.
import { createContext, useContext } from 'react';
const ThemeContext = createContext(null);
export default function MyApp() {
return (
<ThemeContext.Provider value="dark">
<Form />
</ThemeContext.Provider>
)
}
function Form() {
return (
<Panel title="Welcome">
<Button>Sign up</Button>
<Button>Log in</Button>
</Panel>
);
}
function Panel({ title, children }) {
const theme = useContext(ThemeContext);
const className = 'panel-' + theme;
return (
<section className={className}>
<h1>{title}</h1>
{children}
</section>
)
}
function Button({ children }) {
const theme = useContext(ThemeContext);
const className = 'button-' + theme;
return (
<button className={className}>
{children}
</button>
);
}
useState
를 사용하여 컨텍스트 값을 변경할 수 있습니다.
function MyPage() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={theme}>
<Form />
<Button onClick={() => {
setTheme('light');
}}>
Switch to light theme
</Button>
</ThemeContext.Provider>
);
}
이렇게 Context API
를 이용한다면 전역적으로 상태를 관리할 수 있습니다. 다만 관리해야할 상태가 복잡한 구조를 가지거나 상태를 업데이트하는 방식이 단순하지 않다면 useReducer
와 결합하면 전역적인 상태 관리를 효율적으로 할 수 있습니다.
useReducer
는 React
에서 상태를 관리하는데 많이 사용하는 useState
와 같은 React Hook
입니다. 비교적 간단한 상태를 관리하는데에 useState
를 주로 사용하지만 관리해야할 상태가 복잡하거나 논리적인 상태 업데이트가 필요하다면 useReducer
를 이용하는 편이 효율적일 수 있습니다.
const [state, dispatch] = useReducer(reducer, initialArg, init?)
reducer : state
가 업데이트되는 방법을 지정하는 함수입니다. state
와 action
을 인수로 가져와야 하며 업데이트된 state
를 반환해야 합니다.
initialArg : 초기 state
가 계산되는 값입니다. 모든 유형의 값이 될 수 있습니다.
init : 초기 state
를 반환해야 하는 초기화 함수입니다. 지정되지 않은 경우 초기 상태는 으로 설정됩니다 .
useReducer
에서 반환된 dispatch
를 사용하여 state
를 업데이트할 수 있습니다.
dispatch(action);
state
의 업데이트 방식을 지정하는 객체형태의 매개변수입니다. 관례적으로 type
을 통해 업데이트의 방식을 구분하고 선택적으로 추가 정보가 포함할 수 있습니다.dispatch({type:'incremented_age');
function reducer(state, action) {
// ...
}
reducer
함수는 state
와 action
을 인수로 가져와야 하며 state
를 계산하고 반환하는 코드를 작성해야 합니다. 관례적으로는 switch
선언문으로 작성하는 것이 일반적입니다.
아래는 reducer
함수의 예제입니다.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
name: state.name,
age: state.age + 1
};
}
case 'changed_name': {
return {
name: action.nextName,
age: state.age
};
}
}
throw Error('Unknown action: ' + action.type);
}
dispatch
가 보내는 action
의 type
값에 따라 state
가 업데이트가 됩니다. 이렇게 다양한 action
의 type
을 추가하여 논리적으로 상태 관리가 가능합니다.
useReducer
의 state
와 dispatch
함수를 모두 context
에 포함시킨다면 context
의 내부의 컴포넌트들은 props
없이 state
를 공유 및 업데이트가 가능합니다. 즉 state
를 전역으로 관리할 수 있게됩니다.
아래는 직접 작성한 예제입니다.
import React, {
Dispatch,
Reducer,
createContext,
useContext,
useReducer,
} from "react";
const TodoStateConText = createContext([]);
const TodoDispatchConText = createContext(() => {});
function reducer(state, action) {
switch (action.type) {
case "ADD_TODO":
state.push(action.todo);
return state;
case "Delete_TODO":
return state.filter((prev) => prev.id !== action.todo.id);
default:
throw new Error();
}
}
function TodoContext({ children }) {
const [state, dispatch] = useReducer(reducer, []);
return (
<TodoStateConText.Provider value={state}>
<TodoDispatchConText.Provider value={dispatch}>
{children}
</TodoDispatchConText.Provider>
</TodoStateConText.Provider>
);
}
export default TodoContext;
export function useTodoState() {
const state = useContext(TodoStateConText);
return state;
}
export function useTodoDispatch() {
const dispatch = useContext(TodoDispatchConText);
return dispatch;
}
useReducer
을 통해 반환한 state
와 dispatch
를 각각의 context
로 만들어 context
내에서 이들을 공유할 수 있게 만들어줍니다.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import TodoContext from "./TodoContext";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<TodoContext>
<App />
</TodoContext>
);
전체 애플리케이션을 위에 만들어둔 context
로 감싸줍니다. 이제 전체 애플리케이션에서 state
와 dispatch
를 공유할 수 있게 되었습니다.
import React, { useState } from "react";
import Button from "../Atoms/Button";
import Input from "../Atoms/Input";
import { isTodoEditState, todosState } from "../../atom";
import { useTodoDispatch } from "../../TodoContext";
function TodoForm() {
const [newTodo, setNewTodo] = useState("");
const dispatch = useTodoDispatch();
const onChange = (event) => {
const {
target: { value },
} = event;
setNewTodo(value);
};
const onSubmit = (event) => {
event.preventDefault();
dispatch({
type: "ADD_TODO",
todo: {
id: Date.now(),
content: newTodo,
isChecked: false,
isEdited: false,
},
});
setNewTodo("");
};
return (
<Form onSubmit={onSubmit}>
<Input type={"string"} inputValue={newTodo} onChange={onChange} />
<Button buttonValue={"추가하기"} disabled={isTodoEdit} />
</Form>
);
}
export default TodoForm;
useContext
로 구성되어 있는 useTodoDispatch
의 dispatch
를 이용하여 state
를 변경합니다.
import TodoList from "./TodoList";
import { useTodoState } from "../../TodoContext";
function TodoLists() {
const state = useTodoState();
return (
<ul>
{state.map((todo) => (
<TodoList key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoLists;
useContext
로 구성되어 있는 useTodoState
의 state
를 전역으로 사용할 수 있습니다.
전역 상태 관리가 필요하다면 고민 없이 라이브러리를 찾고는 했는데 라이브러리 의존 없이 전역 상태 관리를 구현해서 만족스러웠습니다. 그뿐만 아니라 익숙하지 않아 기피했던 Context API
와 seReducer
를 알아보고 사용해 볼 수 있어서 좋은 경험이었습니다. 물론 Redux
, Recoil
과 같은 상태 관리 라이브러리는 더욱 다양한 기능을 제공하니 상황에 따라서 이들을 택하는 게 더욱 효율적일 수 있지만, 기본적인 상태 관리만 한다면 애플리케이션의 의존성을 줄일 수 있는 위와 같은 방법도 고려해 볼 수 있겠습니다.