** react docs Beta가 이제 공식문서가 되었다. https://react.dev/
리듀서 및 컨텍스트를 사용한 스케일업
리듀서를 사용하면 컴포넌트의 상태 업데이트 로직을 통합할 수 있습니다. 컨텍스트를 사용하면 다른 컴포넌트에 정보를 깊숙이 전달할 수 있습니다. 리듀서와 컨텍스트를 결합하여 복잡한 화면의 상태를 관리할 수 있습니다.
학습 내용
- 리듀서를 컨텍스트와 결합하는 방법
- props를 통해 상태와 디스패치를 전달하지 않도록 하는 방법
- 컨텍스트와 상태 로직을 별도의 파일에 보관하는 방법
리듀서와 컨텍스트 결합
리듀서 소개에서 나온 이 예제에서 상태는 리듀서에 의해 관리됩니다. 리듀서 함수에는 모든 상태 업데이트 로직이 포함되어 있으며 이 파일의 맨 아래에 선언되어 있습니다:
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<>
<h1>Day off in Kyoto</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
let nextId = 3;
const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];
리듀서는 이벤트 핸들러를 짧고 간결하게 유지하는 데 도움이 됩니다. 그러나 앱이 성장함에 따라 또 다른 어려움에 직면할 수 있습니다. 현재 작업 상태와 디스패치 함수는 최상위 TaskApp 컴포넌트에서만 사용할 수 있습니다. 다른 컴포넌트가 작업 목록을 읽거나 변경할 수 있도록 하려면 현재 상태와 이를 변경하는 이벤트 핸들러를 props로 명시적으로 전달해야 합니다.
예를 들어, TaskApp은 작업 목록과 이벤트 핸들러를 TaskList에 전달합니다:
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
그리고 태스크 리스트는 이벤트 핸들러를 태스크에 전달합니다:
<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>
이와 같은 작은 예제에서는 이 방법이 잘 작동하지만 중간에 수십, 수백 개의 컴포넌트가 있는 경우 모든 상태와 함수를 전달하는 것은 상당히 답답할 수 있습니다!
그렇기 때문에 prop을 통해 전달하는 대신 작업 상태와 디스패치 함수를 모두 컨텍스트에 넣을 수 있습니다. 이렇게 하면 트리의 TaskApp 아래에 있는 모든 컴포넌트가 반복적인 "prop 드릴링" 없이 작업을 읽고 작업을 디스패치할 수 있습니다.
리듀서와 컨텍스트를 결합하는 방법은 다음과 같습니다:
- 컨텍스트를 만듭니다.
- 상태와 디스패치를 컨텍스트에 넣습니다.
- 트리의 어느 곳에서나 컨텍스트를 사용합니다.
1단계: 컨텍스트 생성
useReducer Hook은 현재 작업과 이를 업데이트할 수 있는 dispatch 함수를 반환합니다:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
트리에 전달하려면 두 개의 컨텍스트를 별도로 만들어야 합니다:
- TasksContext는 현재 작업 목록을 제공합니다.
- TasksDispatchContext는 컴포넌트가 작업을 디스패치할 수 있는 함수를 제공합니다.
나중에 다른 파일에서 가져올 수 있도록 별도의 파일에서 내보내세요:
import { createContext } from 'react';
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
여기서는 두 컨텍스트 모두에 기본값으로 null을 전달하고 있습니다. 실제 값은 TaskApp 컴포넌트에서 제공합니다.
2단계: state와 dispatch를 컨텍스트에 넣기
이제 TaskApp 컴포넌트에서 두 컨텍스트를 모두 가져올 수 있습니다. useReducer()가 반환한 작업과 dispatch를 가져와서 아래 전체 트리에 제공하세요:
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
...
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
지금은 props와 컨텍스트를 통해 정보를 전달합니다:
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
let nextId = 3;
const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];
다음 단계에서는 props 전달을 제거합니다.
3단계: 트리의 어느 곳에서나 컨텍스트 사용
이제 작업 목록이나 이벤트 핸들러를 트리 아래로 전달할 필요가 없습니다:
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>
대신 작업 목록이 필요한 모든 컴포넌트는 작업 컨텍스트에서 해당 목록을 읽을 수 있습니다:
export default function TaskList() {
const tasks = useContext(TasksContext);
// ...
작업 목록을 업데이트하기위해 모든 컴포넌트가 컨텍스트에서 디스패치 함수를 읽고 호출할 수 있습니다:
export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add</button>
// ...
TaskApp 컴포넌트는 어떠한 이벤트 핸들러도 전달하지 않으며, TaskList 역시 어떠한 이벤트 핸들러도 Task 컴포넌트로 전달하지 않습니다. 각 컴포넌트는 필요한 컨텍스트를 읽습니다:
import { useState, useContext } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskList() {
const tasks = useContext(TasksContext);
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<Task task={task} />
</li>
))}
</ul>
);
}
function Task({ task }) {
const [isEditing, setIsEditing] = useState(false);
const dispatch = useContext(TasksDispatchContext);
let taskContent;
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
text: e.target.value
}
});
}} />
<button onClick={() => setIsEditing(false)}>
Save
</button>
</>
);
} else {
taskContent = (
<>
{task.text}
<button onClick={() => setIsEditing(true)}>
Edit
</button>
</>
);
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
done: e.target.checked
}
});
}}
/>
{taskContent}
<button onClick={() => {
dispatch({
type: 'deleted',
id: task.id
});
}}>
Delete
</button>
</label>
);
}
상태는 여전히 최상위 TaskApp 컴포넌트에 '존재'하며, useReducer로 관리됩니다. 하지만 이제 이러한 컨텍스트를 가져와서 사용하면 트리 아래의 모든 컴포넌트에서 해당 작업과 디스패치를 사용할 수 있습니다.
모든 배선을 단일 파일로 이동
이렇게 할 필요는 없지만 리듀서와 컨텍스트를 모두 단일 파일로 이동하면 컴포넌트를 더욱 깔끔하게 정리할 수 있습니다. 현재 TasksContext.js에는 컨텍스트 선언이 두 개만 포함되어 있습니다:
import { createContext } from 'react';
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
이 파일은 곧 혼잡해질 것입니다! 리듀서를 같은 파일로 옮깁니다. 그런 다음 같은 파일에 새 TasksProvider 컴포넌트를 선언합니다. 이 컴포넌트가 모든 조각을 하나로 묶어줄 것입니다:
- 리듀서로 상태를 관리합니다.
- 이 컴포넌트는 아래 컴포넌트에 두 가지 컨텍스트를 모두 제공합니다.
- JSX를 전달할 수 있도록 자식을 prop으로 받습니다.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
이렇게 하면 TaskApp 컴포넌트에서 모든 복잡성과 배선이 제거됩니다:
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';
export default function TaskApp() {
return (
<TasksProvider>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksProvider>
);
}
TasksContext.js에서 컨텍스트를 사용하는 함수를 내보낼 수도 있습니다:
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
컴포넌트가 컨텍스트를 읽어야 하는 경우 이러한 함수를 통해 이를 수행할 수 있습니다:
const tasks = useTasks();
const dispatch = useTasksDispatch();
이렇게 해도 동작이 변경되지는 않지만 나중에 이러한 컨텍스트를 더 분할하거나 이러한 함수에 로직을 추가할 수 있습니다. 이제 모든 컨텍스트와 리듀서 배선이 TasksContext.js에 있습니다. 이렇게 하면 컴포넌트를 깔끔하고 정돈된 상태로 유지하면서 데이터를 가져오는 위치가 아닌 표시되는 내용에 집중할 수 있습니다:
import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';
export default function TaskList() {
const tasks = useTasks();
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<Task task={task} />
</li>
))}
</ul>
);
}
function Task({ task }) {
const [isEditing, setIsEditing] = useState(false);
const dispatch = useTasksDispatch();
let taskContent;
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
text: e.target.value
}
});
}} />
<button onClick={() => setIsEditing(false)}>
Save
</button>
</>
);
} else {
taskContent = (
<>
{task.text}
<button onClick={() => setIsEditing(true)}>
Edit
</button>
</>
);
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
done: e.target.checked
}
});
}}
/>
{taskContent}
<button onClick={() => {
dispatch({
type: 'deleted',
id: task.id
});
}}>
Delete
</button>
</label>
);
}
TasksProvider는 작업을 처리하는 방법을 알고 있는 화면의 일부로, useTasks는 작업을 읽는 방법으로, useTasksDispatch는 트리 아래의 모든 컴포넌트에서 작업을 업데이트하는 방법으로 생각할 수 있습니다.
Note
useTasks 및 useTasksDispatch와 같은 함수를 사용자 정의 훅이라고 합니다. 함수의 이름이 use로 시작하면 사용자 정의 Hook으로 간주됩니다. 이를 통해 그 안에서 useContext와 같은 다른 Hook을 사용할 수 있습니다.
앱이 성장함에 따라 이와 같은 컨텍스트 리듀서 쌍이 많이 생길 수 있습니다. 이는 트리 깊숙한 곳의 데이터에 액세스하고 싶을 때마다 많은 작업 없이 앱을 확장하고 상태를 끌어올릴 수 있는 강력한 방법입니다.
요약
리듀서와 컨텍스트를 결합하면 모든 컴포넌트가 그 위에 있는 상태를 읽고 업데이트할 수 있습니다.
상태와 디스패치 함수를 아래 컴포넌트에 제공하려면:
- 상태와 디스패치 함수에 대한 컨텍스트 두 개를 생성합니다.
- 리듀서를 사용하는 컴포넌트에서 두 컨텍스트를 모두 제공합니다.
- 컨텍스트를 읽어야 하는 컴포넌트에서 두 컨텍스트 중 하나를 사용합니다.
모든 배선을 하나의 파일로 이동하여 컴포넌트를 더욱 깔끔하게 정리할 수 있습니다.
- 컨텍스트를 제공하는 TasksProvider와 같은 컴포넌트를 내보낼 수 있습니다.
- 또한, 사용태스크나 사용태스크디스패치 같은 사용자 정의 훅을 내보내서 읽을 수도 있습니다.
앱에서 이와 같은 컨텍스트 리듀서 쌍을 여러 개 가질 수 있습니다.
발표요약)
상태가 많고 뎁스가 깊어질 때 props 드릴링을 피하고, 리듀서와 컨텍스트를 조합하여 상태관리를 할 수 있다.
- 컨텍스트 2개를 만든다. (상태를 관리할 컨텍스트, 디스패치를 관리할 컨텍스트)
- 리듀서를 만든다.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
- 상위 컴포넌트에 provider 로 감싸준다.
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
- 이제 provider 내 하위 컴포넌트 어디서나 리듀서로 상태관리가 가능하다.
<button onClick={() => { dispatch({ type: 'deleted', id: task.id }); }}> Delete </button>