11월은 리액트 스터디에 참여했습니다. 이번에는 단계마다 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있는 context API와 useState와 유사하지만 대체로 사용되는 useReducer hook을 다루는 방법에 대해서 알아보겠습니다.
context는 전역 값(데이터)을 여러 컴포넌트에서 공유할 수 있도록 하는 일종의 API입니다. 언제 사용하면 좋은 지 코드와 함께 확인해보겠습니다.
현재 아래의 그림과 같은 상황이 있습니다.
어떤 데이터를 App 컴포넌트에서 E 컴포넌트까지 전달해야 하는 과정이 있습니다. 우리가 context API를 모른다면, props를 이용해 App -> A -> B -> C -> D -> E로 전달할 것입니다. 하지만, 여기서 매우 큰 비효율이 발생합니다. 사실상, A, B, C, D 컴포넌트는 데이터를 넘겨줄 뿐, 사용하지 않습니다.
props로 넘겨주는 데이터가 하나이면, 사실 상 위와 같이 App에서 E까지 중간단계들을 거쳐서 props로 넘겨줘도 됩니다. 하지만, props로 넘겨주는 데이터가 여러 개라면, 코드를 작성하는 개발자 입장에서도 가독성이 떨어져서, 이후의 유지보수 또는 어떤 값이 전역 데이터인지 판단하기 힘들어질 것입니다.
이 때, context를 사용하면 효율적으로 바꿀 수 있습니다.
예시를 들어서, 어떻게 context를 사용하면 좋을 지 보겠습니다.
위와 같은 간단한 화면을 구현해 보았습니다. 컴포넌트의 구조는 App -> TodoList -> Todo의 구조로 되어있습니다.
위의 상황에서 해당 todo 마다 누가, 언제 썼는지에 해당하는 데이터를 App 컴포넌트에서 넘겨준다고 했을 때, context를 활용해보겠습니다.
// App.js
import './App.css';
import { useEffect, useState, createContext } from "react";
import TodoList from "./components/TodoList";
export const AppContext = createContext();
function App() {
const [ loading, setLoading ] = useState(null);
const provider = {
writer : 'kyle',
date: '22-11-02',
}
const todos = [
{
title: '1',
desc: 'first Todo'
},
{
title: '2',
desc: 'second Todo'
},
{
title: '3',
desc: 'third Todo'
},
{
title: '4',
desc: 'fourth Todo'
},
{
title: '5',
desc: 'fifth Todo'
}
]
useEffect(() => {
setTimeout(() =>
{ setLoading(true)}
, 1000)
}, [])
return (
<AppContext.Provider value={provider}>
{loading ? <div style={{margin : '20px'}}>
<h1>Welcome, kyle</h1>
<TodoList todos={todos}/>
</div> : 'Loading ...'}
</AppContext.Provider>
);
}
export default App;
// TodoList.js
import React from "react";
import Todo from './Todo'
export default function TodoList ({todos}) {
return (
<>
{todos.map(todo => <Todo key={todo.title} todo={todo}/>)}
</>
)
}
// Todo.js
import React, { useContext } from "react";
import { AppContext } from '../App'
export default function Todo ({todo}) {
const provider = useContext(AppContext);
console.log(provider);
return (
<>
<div style={{marginBottom: '20px'}}>
<div>{todo.title}</div>
<span style={{display: 'inline-block', marginRight: '10px'}}>{todo.desc}</span>
<button>x</button>
<div>
<strong>{provider.writer}</strong>
<span> | </span>
<strong>{provider.date}</strong>
</div>
</div>
</>
)
}
1. createContext()
createContext는 context 객체를 만드는 React 메서드입니다.
import { createContext } from 'react';
const AppContext = createContext();
2. Context.Provider
context 객체 내부에는 Provider라는 컴포넌트를 내장하고 있습니다. 이 Provider는 context를 subscribe하고 있는 컴포넌트들에게 context의 변화를 알려주는 역할을 하는데, 이것이 가능한 이유는 Context.Provider
컴포넌트가 전역 데이터를 공유할 하위 컴포넌트들을 감싼 형태로 코드를 구성하기 때문입니다.
<AppContext.Provider value={provider}>
{loading ? <div style={{margin : '20px'}}>
<h1>Welcome, kyle</h1>
<TodoList todos={todos}/>
</div> : 'Loading ...'}
</AppContext.Provider>
Provider 컴포넌트는 value를 props로 하위에 있는 컴포넌트들에 값을 전달합니다. 값을 전달받을 수 있는 컴포넌트의 수는 제한이 없고, 이 value가 필요한 컴포넌트에서는 useContext(context)
hook을 활용하여 이 값을 사용 가능합니다. 위의 코드에서는 전달하는 값이 provider라는 객체이겠죠?
3. useContext
import React, { useContext } from 'react';
import { AppContext } from '../App'
const provider = useContext(AppContext);
전역 데이터 형태로 존재하는 provider를 useContext hook을 활용해서 가져와, 컴포넌트 렌더링 시 사용가능합니다.
다음과 같이 렌더링 됩니다.
지금은 nested components가 2개 밖에 없어, 사실은 props로 여러 번 넘겨 데이터를 활용해도 되지만, 중간에 많은 컴포넌트가 있다고 가정하면, 제일 상위의 컴포넌트에 context를 생성하고, 필요한 전역 데이터를 원하는 컴포넌트에서 사용하는 것이 효율적이라는 것을 알 수 있을 것입니다.
userReducer hook은 useState를 대체하여 사용되는 hook으로 상태 관리에 사용됩니다.
useReducer의 구조는 다음과 같습니다.
const [state, dispatch] = useReducer(reducer, initialState);
1) 다수의 하윗값을 포함하는 복잡한 정적 로직을 만드는 경우나 2) 다음 state가 이전 state에 의존적인 경우에 보통 useState보다 useReducer를 선호하게 됩니다. 특히, 값이나 값을 다루는 함수가 많아지면, useState가 많아져 관리해야 할 state가 많아지기 때문에, 이 때 useReducer를 사용하면 효율적입니다.
1)의 경우는 state를 변경해주는 로직이나 계산이 길어지는 경우, 컴포넌트의 길이가 길어지는 문제가 발생할 수 있기 때문에 useReducer를 이용하면, 복잡한 state를 다룰 때 state를 변경해주는 함수를 따로 떨어뜨려서 상태를 변경한 후에 반환해 주면, useState보다 효과적으로 관리가 가능합니다.
즉, useState와 크게 다른 점은 인자로 reducer라는 순수함수와 initialState, 즉 초기 상태값이 담기는 데, state를 변경하는 로직인 reducer라는 함수를 컴포넌트 밖에 위치시켜 분리시켜 작성합니다.
+버튼을 클릭하면, 숫자가 증가하고, - 버튼을 클릭하면 숫자가 감소하는 컴포넌트를 다음과 같이 작성하였습니다.
import React, { useReducer } from "react";
import Todo from './Todo'
const initCount = {
count: 0,
}
const reducer = (state, action) => {
switch (action.type) {
case 'plus':
return {count: state.count + 1};
case 'minus':
return {count: state.count - 1};
default:
break;
}
}
export default function TodoList ({todos}) {
const [state, dispatch] = useReducer(reducer, initCount);
return (
<>
{todos.map(todo => <Todo key={todo.title} todo={todo}/>)}
<span style={{padding: '5px 20px', border: '1px solid black'}}>{state.count}</span>
<div style={{marginTop: '20px'}}>
<button onClick={ () => dispatch({type: 'plus'}) } style={{marginRight: '10px', padding: '3px 6px'}}>+</button>
<button onClick={ () => dispatch({type: 'minus'}) } style={{marginRight: '10px', padding: '3px 8px'}}>-</button>
</div>
</>
)
}
사실, 위의 예시는 useState로도 충분히 가능합니다. 하지만, 위에서 언급했듯 다뤄야 할 값 또는 값을 다루는 함수가 많아질 경우, useReducer 를 사용하면 효과적이기 때문에 알아두시는 것이 좋습니다.
이 useReducer는 앞에서 다룬 contextAPI와 같이 자주 사용됩니다.
다음의 예시에서 전역 상태 관리 시에 context와 useReducer가 어떻게 함께 사용되는지 확인해보겠습니다.
// App.js
import './App.css';
import {useEffect, useState, createContext, useReducer} from "react";
import TodoList from "./components/TodoList";
export const AppContext = createContext();
const initCount = {
number: 5,
}
const reducer = (count, action) => {
switch (action.type){
case 'plusCount' :
return {...count, number: action.number};
default:
break;
}
}
function App() {
const [ loading, setLoading ] = useState(null);
const provider = {
writer: 'kyle',
date: '22-11-02',
}
const [count, countDispatch] = useReducer(reducer, initCount)
const todos = [
{
title: '1',
desc: 'first Todo'
},
{
title: '2',
desc: 'second Todo'
},
{
title: '3',
desc: 'third Todo'
},
{
title: '4',
desc: 'fourth Todo'
},
{
title: '5',
desc: 'fifth Todo'
}
]
useEffect(() => {
setTimeout(() =>
{ setLoading(true)}
, 1000)
}, [])
return (
<AppContext.Provider value={ {count, countDispatch} }>
<div style={{width:'100%', height: '100vh', margin : '20px'}}>
{loading ?
<div>
<h1>Welcome, kyle</h1>
<strong style={{display:'inline-block', marginBottom: '20px', fontSize: '1.5rem'}}>Total Count : {count.number}</strong>
<TodoList todos={todos}/>
</div>
: 'Loading ...'}
</div>
</AppContext.Provider>
);
}
export default App;
// TodoList.js
import React, { useReducer, useContext } from "react";
import {AppContext} from "../App";
import Todo from './Todo'
const initCount = {
count: 0,
}
const reducer = (state, action) => {
switch (action.type) {
case 'plus':
return {count: state.count + 1};
case 'minus':
return {count: state.count - 1};
default:
break;
}
}
export default function TodoList ({todos}) {
const [state, dispatch] = useReducer(reducer, initCount);
const { count, countDispatch } = useContext(AppContext);
console.log(count)
return (
<>
<button onClick={() => countDispatch({type:'plusCount', number : count.number + 1})}>plusCount</button>
{todos.map(todo => <Todo key={todo.title} todo={todo}/>)}
<span style={{padding: '5px 20px', border: '1px solid black'}}>{state.count}</span>
<div style={{marginTop: '20px'}}>
<button onClick={ () => dispatch({type: 'plus'}) } style={{marginRight: '10px', padding: '3px 6px'}}>+</button>
<button onClick={ () => dispatch({type: 'minus'}) } style={{marginRight: '10px', padding: '3px 8px'}}>-</button>
</div>
</>
)
}
App 컴포넌트에서 useReducer와 context를 사용합니다. provider 컴포넌트를 구독하는 하위 컴포넌트들에게 useReducer가 반환하는 count와 countDispatch 함수를 전역으로 사용하게 설정합니다.
TodoList 컴포넌트에서 plusCount라는 버튼에 onClick 이벤트 리스너를 붙이고, 이벤트 핸들러에는 countDispatch 함수를 사용하며, 이 함수의 인자로 action 객체를 활용하면, 상태를 변경시켜주는 App 컴포넌트의 순수 함수인 reducer 함수에서 상태를 변경하여, 변경된 count 값을 렌더링할 것입니다.
결과물은 다음과 같습니다.
이렇게, 하위 컴포넌트에서 해당 상태를 변경해도, 상위 컴포넌트인 App 컴포넌트에서 상태가 변경된 것이 반영됩니다. 전역 데이터로 공유하고 있는 값이 바로 useReducer가 반환하는 count와 countDispatch 함수이기 때문입니다.
참고로, onClick 이벤트 리스너에 이벤트핸들러 함수로, countDispatch를 한 번 감싸서 넣는 이유는, 감싸서 넣지 않는 경우 클릭 이벤트가 발생할 때, 이 함수가 실행되는 것이 아니라, 컴포넌트가 렌더링 시 함수가 호출되어, 바로 countDispatch 함수가 실행되기 때문입니다.
위와 같은 경우 뿐만 아니라, 예를 들어 웹페이지의 header 영역에 라이트 또는 다크 모드로 변환하는 컴포넌트가 위치해 있다고 가정할 때, useReducer가 반환하는 값을 context를 이용하여 전역 데이터로 관리할 수 있도록 넘겨주면, 테마 모드를 토글 가능해질 것입니다.
이렇게 useReducer hook을 context와 같이 사용하면 강력해집니다!
전역 상태 관리의 예로는, 1) 로그인 정보, 2) 화이트-다크 테마 설정 등이 있습니다.
Context는 꼭 전역 상태 관리에만 사용되는 것은 아닙니다.
1. props drilling 현상을 방지하기 위한 방법으로 사용됩니다
2. props가 아닌 다른 방식으로 데이터를 전달하는 방법으로 사용됩니다.
3. 재사용성이 높은 컴포넌트를 만들 때도 유용합니다.
Context는 전역 상태 관리를 할 수 있는 수단일 뿐이기 때문에, 이 상태 관리를 도와주는 많은 외부 라이브러리들을 사용하면 유용하게 상태 관리를 할 수 있습니다.
이렇게 context와 useReducer를 어떻게 사용할 수 있는지, 예시를 들어가면서 정리해보았습니다. 다음에는 리액트에 TypeScript를 적용하는 방법에 대해서 정리해보겠습니다.