일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 타입스크립트 리액트
- typeScript
- JavaScript
- React Hooks
- 타입스크립트
- 내일배움캠프 프로젝트
- useEffect
- 파이썬 for in
- 내일배움캠프 최종 프로젝트
- 프로그래머스
- 리액트 훅
- 리액트
- 내배캠 프로젝트
- 리액트 공식문서
- Next 팀 프로젝트
- 자바스크립트
- 파이썬 enumerate
- 파이썬 for
- 파이썬 딕셔너리
- 파이썬 replace
- 리액트 프로젝트
- REACT
- 내일배움캠프
- 코딩테스트
- tanstack query
- 타입스크립트 props
- 한글 공부 사이트
- 파이썬 slice
- 파이썬 반복문
- useState
- Today
- Total
sohyeon kim
[React] 리액트 공식문서 정리 3(3) : State 관리하기 - Reducer, Context, 리듀서와 컨텍스트로 상태 관리 본문
[React] 리액트 공식문서 정리 3(3) : State 관리하기 - Reducer, Context, 리듀서와 컨텍스트로 상태 관리
aotoyae 2024. 12. 6. 14:21
💡 리액트 공식문서 정리 : State 관리하기 - State 로직을 리듀서로 작성하기, Context 를 사용해 데이터를 깊게 전달하기, Reducer 와 Context 로 앱 확장하기
5. State 로직을 reducer 로 작성하기
여러 이벤트 핸들러를 통해 많은 state 업데이트가 이루어지는 컴포넌트는 관리하기 어려울 수 있다.
이를 위해 컴포넌트 외부에서 'reducer'라는 단일 함수를 사용해 모든 state 업데이트 로직을 통합할 수 있다.
이벤트 핸들러는 오로지 사용자의 'action'만을 명시하므로 간결해진다.
reducer 를 사용해 state 로직 통합하기
아래의 TaskApp 컴포넌트는 state 에 tasks 배열을 보유하고 있고, 세 가지 이벤트 핸들러를 통해 task 를 추가, 제거, 수정한다.
export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);
function handleAddTask(text) {
setTasks([...tasks, {
id: nextId++,
text: text,
done: false
}]);
}
function handleChangeTask(task) {
setTasks(tasks.map(t => {
if (t.id === task.id) {
return task;
} else {
return t;
}
}));
}
function handleDeleteTask(taskId) {
setTasks(
tasks.filter(t => t.id !== taskId)
);
}
각 이벤트 핸들러는 state 를 업데이트하기 위해 setState 를 호출한다.
컴포넌트가 커질수록 state 를 다루는 로직의 양도 늘어나게 되는데, 복잡성은 줄이고 접근성을 높이기 위해
컴포넌트 내부의 state 로직을 외부의 reducer(단일 함수) 로 옮겨보자.
- state 를 설정하는 것에서 action 을 dispatch 함수로 전달하는 것으로 변경
- reducer 함수 작성
- 컴포넌트에서 reducer 사용
1️⃣ state 를 설정하는 것에서 action 을 dispatch 함수로 전달하는 것으로 변경 : 사용자의 의도를 더 명확히 설명
위 예시의 이벤트 핸들러는 state 를 설정함으로써 무엇을 할 것인지 지시하고 있다.
이벤트 핸들러 내 state 설정 로직을 전부 지우고, 'action'을 전달해 '사용자가 방금 한 일'을 지정한다.
즉, 'task 를 설정' 하는 대신 'task 를 추가/변경/삭제'하는 action 을 전달하는 것!
👀 action 객체엔 어떤 것이든 넣을 수 있지만, 일반적으로 어떤 상황이 발생했는지에 대한 최소한의 정보를 담아야 한다.
function handleAddTask(text) {
dispatch({
// --- action 객체 ---
type: 'added', // 발생한 일만 설명하는 문자열, 이외 정보는 아래처럼 다른 필드에
id: nextId++,
text: text,
// ------------------
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
2️⃣ reducer 함수 작성하기
위에서 지운 state 에 대한 로직은 이 reducer 함수에 작성한다.
이 함수는 현재 state 값과 action 객체, 이렇게 두 인자를 받아 다음 state 값을 반환한다.
function tasksReducer(tasks, action) { // 현재 state 인 tasks, action 객체
switch (action.type) {
case 'added': { // 각자 다른 case 내에 선언된 변수들이 서로 충돌하지 않도록 중괄호로 감싸는 걸 추천
return [...tasks, { // React가 설정하게될 다음 state 값 반환
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task; // React가 설정하게될 다음 state 값 반환
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id); // React가 설정하게될 다음 state 값 반환
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
3️⃣ 컴포넌트에서 reducer 사용하기
마지막으로 컴포넌트에 tasksReducer 를 연결한다.
import { useReducer } from 'react';
//...
// const [tasks, setTasks] = useState(initialTasks); 기존에 사용한 useState 는 제거
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer hook 은 두 개의 인자를 받는다.
- reducer 함수
- 초기 state 값
그리고 아래와 같이 반환한다.
- state 를 담을 수 잇는 값
- dispatch 함수(사용자의 action 을 reducer 함수에게 '전달하게 될')
useState VS useReducer : reducer 가 좋은 점만 있는 것은 아니다. 둘을 비교해보자.
- 코드 크기 : 일반적으로 useState 를 사용하면 미리 작성해야 하는 코드가 줄어들지만, useReducer 를 사용하면 reducer 함수와 action 을 전달하는 부분 둘 다 작성해야 한다. 하지만 여러 이벤트 핸들러에서 비슷한 방식으로 state 를 업데이트하는 경우라면, useReducer 를 사용해 코드 양을 줄일 수 있다.
- 가독성 : useState 로 간단한 state 를 업데이트하는 경우 가독성은 좋은편. 하지만 복잡한 구조의 state 를 다루게 되면 코드 양이 많아져 읽기 어려워질 수 있다. 이 경우 useReducer 로 업데이트 로직이 어떻게 동작하는지, 이벤트 핸들러를 통해 무엇이 발생했는지 구현한 부분을 명확히 구분할 수 있다.
- 디버깅 : useState 사용 중 버그를 발견했을 때, 왜, 어디서 state 가 잘못 설정됐는지 찾기 어려울 수 있다. useReducer 를 사용하면 콘솔 로그를 reducer 에 추가해 어떤 action 에 의한 버그인지 확인할 수 있다. 각 action 이 올바르다면 버그가 reducer 로직 자체에 있다는 것도 알 수 있다. 하지만 useState 보다 더 많은 코드로 단계별 디버깅을 해야하는 점도 있긴 하다.
- 테스팅 : reducer 는 컴포넌트에 의존하지 않는 순수 함수. 이는 reducer 를 독립적으로 분리해 내보내거나 테스트할 수 있다는 것을 의미한다. 복잡한 state 업데이트 로직의 경우, reducer 가 특정 초기 state 및 action 에 대해 특정 state 를 반환한다고 생각하고 테스트하는 것이 유용할 수 있다.
➡️ 컴포넌트에서 잘못된 방식의 state 업데이트로 버그가 자주 발생하거나, 해당 코드에 더 많은 구조를 도입하고 싶다면 reducer 사용을 권장한다. 이때 모든 부분에 reducer 를 적용하지 않아도 된다. useState 와 useReducer 를 마음대로 섞고 매치해보자.
reducer 잘 작성하기
- reducer 는 반드시 순수해야 한다. state 업데이트 함수와 비슷하게 reducer 는 렌더링 중 실행된다. (action 은 다음 렌더링까지 대기) 이것은 reducer 는 반드시 순수해야 한다는 걸 의미. 즉, 입력 값이 같아면 결과 값도 항상 같아야 한다. 요청을 보내거나 사이드 이펙트(컴포넌트 외부에 영향을 미치는 작업)를 수행해선 안된다. reducer 는 객체와 배열을 변경하지 않고 업데이트해야 한다.
- 각 action 은 데이터 안에 여러 변경들이 있더라도 하나의 사용자 상호작용을 설명해야 한다. 예시로, 사용자가 reducer 가 관리하는 5개의 필드가 있는 양식에서 '재설정'을 누른 경우, 5개의 개별 set_field action 보다는 하나의 reset_form action 을 전송하는 것이 더 합리적이다. 모든 action 을 reducer 에 기록하면 어떤 상호작용이나 응답이 어떤 순서로 일어났는지 재구성할 수 있을 만큼 로그가 명확해야 한다. 디버깅에 도움이 되므로
➕ Immer 로 간결하게 reducer 를 작성할 수 있다.
❗️ action 객체의 type 은 '사용자가 무엇을 했는지'를 설명한다.
위 예시에선 채팅 text 를 변경하면 수정된 메세지와 함께 'edited_message' type 을 전송하고,
Send 버튼을 클릭하면 채팅을 초기화하기 위해 빈 메세지와 'edited_message' type 을 전송하고 있다.
이는 잘 동작하는 코드이지만, 사용자의 관점에서 보았을 때 input 필드에 텍스를 작성하는 것과 메세지를 전송하는 것은 다른 행위이다.
이를 반영하기 위해 send_message 라는 새로운 action 을 만들어 reducer 에서 별도로 작성해보자.
export function messengerReducer(
state,
action
) {
switch (action.type) {
//..
case 'edited_message': {
return {
...state,
message: action.message
};
}
case 'sent_message': {
return {
...state,
message: ''
};
}
결과적으로 두 방법은 동일하게 동작하지만,
action 객체의 type 은 'state 가 어떻게 변경되길 원하는지' 보다, '사용자가 무엇을 했는지' 를 설명해야 한다는 점을 기억하자.
또한 두 방법 모두 reducer 안에 alert 를 작성하지 않는 것이 중요하다. reducer 는 순수함수이어야 하므로, 이 안에선 오직 다음 state 값을 계산하기 위한 작업만 해야 한다. 이런 부분은 이벤트 핸들러 안에서 수행해야 한다. (이 같은 실수를 방지하기 위해 React 는 Strict 모드에서 reducer 를 여러 번 호출한다.)
6. Context 를 사용해 데이터를 깊게 전달하기
props 가 중간에 많은 컴포넌트를 거쳐야(지나쳐야) 하거나, 그 많은 컴포넌트에서 동일한 정보가 필용한 경우, props 를 전달하는 것이 번거롭고 불편할 수 있다. ➡️ props drilling
Context 는 부모 컴포넌트가 그 아래의 트리 전체에(깊이에 상관없이) 데이터를 전달할 수 있도록 해준다.
데이터를 '순간이동' 시켜보자!
다음 예시는 level 을 받아 텍스트의 크기 조정을 하고 있다.
같은 section 내 여러 제목이 항상 동일한 크기를 가져야 한다고 가정해보자.
지금은 각 <Heading> 에 level prop 을 전달하고 있다.
<Section> 컴포넌트에 level 을 전달하고 <Heading> 이 가장 가까운 section 의 레벨을 알아차리게 할 수 있을까?
Context 를 활용하자!
1️⃣ Context 생성하기 : LevelContext 라고 이름을 지어보자.
// LevelContext.js
import { createContext } from 'react';
export const LevelContext = createContext(1);
// 인자는 기본값. 여기서 1은 가장 큰 제목 레벨을 나타내지만 모든 종류의 값을(객체까지) 전달할 수 있다.
2️⃣ Context 사용하기 : Heading 에선 LevelContext 를 사용한다.
// Heading.js
import { useContext } from 'react'; // Hook 과
import { LevelContext } from './LevelContext.js'; // 생성한 Context 불러오기
//export default function Heading({ level, children }) {
// ...
//}
export default function Heading({ children }) {
const level = useContext(LevelContext); // level prop 을 제거하고 LevelContext 를 읽는다.
// ...
}
3️⃣ Context 제공하기 : Section 에선 LevelCOntext 를 제공한다.
LevelContext 를 자식들에게 제공하기 위해 provider 로 감싸준다.
import { LevelContext } from './LevelContext.js';
export default function Section({ level, children }) {
return (
<section className="section">
<LevelContext.Provider value={level}>
{children}
</LevelContext.Provider>
</section>
);
}
이제 React 는 Section 내 컴포넌트가 LevelContext 를 요구하면 Level 을 전해주고,
컴포넌트는 그 위에 있는 UI 트리에서 가장 가까운 <LevelContext.Provider> 의 값을 사용한다.
➕ 컴포넌트 내에서 context 를 이용해 전달하기 : 주변에 적응하는 컴포넌트 작성하기
지금은 각 Section 에 level 을 수동으로 지정하고 있다. 하지만 Context 를 통해 상위 컴포넌트에서 정보를 읽어올 수 있으므로 각 Section 은 그 위의 Section 에서 level 을 읽고 자동으로 level + 1 을 전달할 수 있다.
export default function Section({ children }) {
const level = useContext(LevelContext);
return (
<section className="section">
<LevelContext.Provider value={level + 1}>
{children}
</LevelContext.Provider>
</section>
);
}
이렇게 바꾸면 Section 과 Heading 둘 모두에 level 을 전달할 필요가 없다.
🙋♂️ 이 예시에선 하위 컴포넌트가 context 를 오버라이드 할 수 있는 방법을 시각적으로 보여주기에 제목 레벨을 사용한다.
하지만 context 는 다른 상황에서도 유용하다. 현재 색상 테마, 현재 로그인된 사용자 등 전체 하위 트리에 필요한 정보를 전달할 수 있다.
- 테마 지정 : 사용자가 모양을 변경할 수 있는 앱의 경우(e.g. 다크 모드), provider 를 앱 최상단에 두고 시각적으로 조정이 필요한 컴포넌트에서 context 를 사용할 수 있다.
- 현재 계정 : 로그인한 사용자를 알아야하는 컴포넌트에서 활용. 일부 앱에선 동시에 여러 계정을 운영할 수도 있다.(e.g. 다른 사용자로 댓글을 남기는 경우) 이런 경우 UI 의 일부를 서로 다른 현재 계정 값을 가진 provider 로 감싸주는 것이 편리하다.
- 라우팅 : 대부분의 라우팅 솔루션은 현재 경로를 유지하기 위해 내부적으로 conntext 를 사용한다. 이것이 모든 링크의 활성화 여부를 '알 수 있는' 방법.
- 상태 관리 : 앱이 커지면 결국 앱 상단에 수많은 state 가 생기게 된다. 아래 멀리 떨어진 많은 컴포넌트가 그 값을 변경하고 싶을 수 있다. 흔히 reducer 를 context 와 함께 사용하는 것은 복잡한 state 를 관리하고 번거로운 작업 없이 멀리 있는 컴포넌트까지 값을 전달하는 방법이다.
Context 는 정적인 값으로 제한되지 않는다. 다음 렌더링 시 다른 값을 준다면 React 는 아래의 모든 컴포넌트에서 값을 갱신한다.
이것이 context 와 state 가 자주 조합되는 이유!
일반적으로 트리의 다른 부분에서 멀리 떨어져 있는 컴포넌트들이 같은 정보가 필요하다는 것은 context 를 사용하기 좋다는 징조이다.
Context 의 작동 방식은 CSS 속성 상속을 연상시킨다.
CSS 에서 <div> 에 대해 color: blue 를 지정하고, 중간에 있는 다른 DOM 노드가 green 으로 재정의되지 않는 한 그 안의 모든 노드는 그 색상을 상속한다. 마찬가지로, React 에서 위에서 가져온 어떤 context 를 재정의하는 유일한 방법은 자식들을 다른 값을 가진 context provider 로 래핑하는 것!
CSS 에서 color 와 background-color 같이 다른 속성들은 서로 영향을 주지 않는 것처럼 서로 다른 React context 는 영향을 주지 않는다. createContext() 로 만든 각각의 context 는 완벽히 분리되어 있고 특정 context 를 사용 및 제공하는 컴포넌트끼리 묶여 있다. 하나의 컴포넌트에서 문제 없이 서로 다른 context 를 사용하거나 제공할 수 있다.
❗️ Context 를 사용하기 전에 고려할 것
Context 는 남용하기 쉬운데, 어떤 props 를 여러 레벨 깊이로 전달해야 한다 해서 바로 context 에 넣어야 하는 것은 아니다.
- 먼저 Props 전달하기로 시작하자. 사소한 컴포넌트들이 아니라면 여러 props 가 여러 컴포넌트를 지나가는 것은 이상한 일이 아니다. 힘든 일처럼 느껴질 수 있지만 어떤 컴포넌트가 어떤 데이터를 사용하는지 매우 명확히 해준다. 데이터의 흐름이 props 를 통해 분명해져 코드 유지보수에도 좋다.
- 컴포넌트를 추출하고 JSX 를 children 으로 전달하기. 데이터를 사용하지 않는 중간 컴포넌트 층을 통해 어던 데이터를 전달하는(더 아래로 보내기만 하는) 경우엔 컴포넌트 추출을 잊은 경우가 많다. 예를 들어 posts 처럼 직접 사용하지 않는 props 를 <Layout posts={posts} /> 처럼. 대신 Layout 은 children 을 prop 으로 받고, <Layout><Posts posts={posts} /></Layout> 을 렌더링하자. 이렇게 하면 데이터를 지정하는 컴포넌트와 데이터가 필요한 컴포넌트 사이의 층수가 줄어든다.
7. Reducer 와 Context 로 앱 확장하기
컴포넌트의 state 업데이트 로직을 통합해주는 Reducer, 멀리 떨어져있는 컴포넌트에 정보를 전달해주는 Context,
이 둘을 함께 사용해 복잡한 화면의 state 를 관리해보자.
Reducer 와 context 결합하기
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,
});
}
// ...
}
Reducer 는 이벤트 핸들러를 짧고 간결하게 유지하는 데 도움이 된다. 그러나 앱이 커지면 다른 어려움에 부딪힐 수 있다.
현재 tasks state 및 dispatch 함수는 최상위 TaskApp 컴포넌트에서만 사용 가능하다.
다른 컴포넌트가 작업 목록을 읽거나 변경하려면 현재 state 와 이벤트 핸들러를 props 로 전달해야 한다.
간단한 예시에선 잘 동작하지만, 수십 수백개의 컴포넌트를 거쳐 state 나 함수를 전달하긴 쉽지 않다.
그러니 tasks state 와 dispatch 함수를 context 에 넣어 관리하자!
➡️ 이제 트리에서 TaskApp 아래 모든 컴포넌트가 props drilling 없이 tasks 와 dispatch actions 를 읽을 수 있게 된다.
1️⃣ Context 생성
트리를 통해 전달하려면, 두 개의 별개 context 를 생성해야 한다.
import { createContext } from 'react';
export const TasksContext = createContext(null); // 현재 tasks 리스트 제공
export const TasksDispatchContext = createContext(null); // 컴포넌트에서 action 을 dispatch 하는 함수 제공
// 두 context 에 모두 기본값으로 null 을 전달하고 있는데, 실제 값은 TaskApp 에서 제공될 것.
2️⃣ State 와 dispatch 함수를 context 에 넣기
이제 TaskApp 컴포넌트에서 두 context 를 모두 가져올 수 있다.
useReducer() 에서 반환된 tasks 및 dispatch 를 가져와 트리 전체에 제공하자.
3️⃣ 트리 안에서 context 제공하기 : prop 을 통한 전달 제거
이제 tasks 리스트나 이벤트 핸들러를 트리 아래로 전달하지 않아도 된다.
대신 필요한 컴포넌트에서는 TaskContext 에서 tasks 를 읽을 수 있고, 리스트를 업데이트 하기 위해 context 의 dispatch 함수를 읽고 호출할 수 있다.
TaskApp, TaskList, Task 컴포넌트는 어떤 이벤트 핸들러도 아래로 전달하지 않으며, 각 컴포넌트는 필요한 context 를 읽는다.
State 는 여전히 최상위 TaskApp 컴포넌트에서 useReducer 로 관리되고 있다.
그러나 이제 context 를 가져와 트리 아래 모든 컴포넌트에서 해당 tasks 및 dispatch 를 사용할 수 있다.
하나의 파일로 합치기
Reducer 와 context 를 모두 하나의 파일에 작성해 컴포넌트들을 조금 더 정리할 수 있다.
새 컴포넌트인 TasksProver 에 기존에 선언했던 두 context 와 Reducer 를 작성한다. 이 컴포넌트는 모든 것을 하나로 묶는 역할을 하게 된다.
- Reducer 로 state 를 관리
- 두 context 를 모두 하위 컴포넌트에 제공
- children 을 prop 으로 받기에 JSX 전달 가능
이렇게 하면 TaskApp 컴포넌트의 복잡성과 연결이 모두 제거된다.
TasksContext.js 에서 context 를 사용하기 위한 use 함수들도 내보낼 수 있고, 이 함수를 통해 컴포넌트에서 context 를 읽을 수 있다.
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
const tasks = useTasks();
const dispatch = useTasksDispatch();
동작이 바뀌는 건 아니지만, 다음에 context 를 더 분리하거나 함수들에 로직을 추가하기 쉬워졌다.
이제 모든 context 와 reducer 는 TasksContext.js 에 있다. 컴포넌트들이 데이터를 어디서 가져오는지가 아닌 무엇을 보여줄 것인지에 집중할 수 있도록 깨끗이 정리되었다.
🔗 https://ko.react.dev/learn/extracting-state-logic-into-a-reducer