[React] 순수 redux에 비동기 추가하기
1. 설정 및 세팅
1.1 환경 설정
axios 환경 설치 / json-server 환경 설치
yarn add axios json-server
1.2 db.json 파일 생성
{
"todos": [
{
"id": "1",
"title": "오늘 뭐하지?",
"contents": "리액트 공부하자!",
"isDone": true
},
{
"id": "2",
"title": "저녁 뭐 먹지",
"contents": "감자탕 먹고싶당",
"isDone": true
}
]
}
1.3 json-server 실행
npx json-server db.json --port [원하는 포트]
2. 프로세스: data를 redux로 관리하기⭐
① useEffect로 데이터 가져오기
컴포넌트 마운트 시 useEffect를 사용해 비동기로 서버에서 데이터를 가져오고(axios), dispatch로 Redux 상태를 초기화한다.
useEffect(() => {
const getTodos = async () => {
try {
const { data } = await axios.get("http://localhost:4000/todos");
dispatch(initialization(data));
} catch (error) {
console.error(error);
}
};
getTodos();
}, []);
② dispatch 사용하기
서버에서 데이터를 가져온 후 initialization 액션을 dispatch해 Redux 스토어를 초기화한다.
dispatch(initialization(data));
③ useSelector로 상태 읽기
useSelector
를 사용해 Redux 스토어의 todos 상태를 컴포넌트에서 읽어온다.
const todos = useSelector((state) => state.todos);
④ 추가 Click
- 새로운 할 일을 추가하는 함수에서 axios로 서버에 POST 요청을 보내고,
dispatch
로addTodo
액션을 호출해 Redux 상태를 업데이트한다.- dispatch(클라이언트 업데이트)를 안하고 addDoc(db 업데이트)만 했을 때, 추가된 항목이 안보임
- addDoc을 안하고 dispatch만 했을 때, DB에 저장이 안됨 -> 새로고침시 문제
⭐ addDoc과 dispatch를 일치 시키는게 중요하다!(스프레드 연산자 사용하기)
💡 addDoc과 dispatch를 한꺼번에 처리할 수 있는게 “RTK”이다.
const newTodo = { title, contents, isDone: false };
try {
const response = await axios.post("http://localhost:4000/todos", newTodo);
const newId = response.data.id;
dispatch(addTodo({ ...newTodo, id: newId }));
setTitle("");
setContents("");
} catch (error) {
alert("서버 통신 중 에러가 발생했습니다. 다시 시도해주세요.");
console.error(error);
}
- 삭제와 수정도 마찬가지로
remove Doc
orUpdate Doc
와dispatch
를 같이 해줘야 한다! - ⭐ id를 db에 있는 값으로 재구성 시키는 작업을 하는 것이 중요하다.
- (기존에 있던 객체를 스프레드 연산자로 펼친다음에 id 부여할 것!)
3. 구현하기
3.1 module 변경
import { v4 as uuidv4 } from "uuid";
// action items
const ADD_TODO = "ADD_TODO";
const REMOVE_TODO = "REMOVE_TODO";
const SWITCH_TODO = "SWITCH_TODO";
const INITIALIZATION = "initialization"; // 기존의 todos state를 모두 지워버리고, 외부 DB로부터 가져온 값으로 갱신
/* todo 객체를 입력받아, 기존 todolist에 더함 */
export const addTodo = (payload) => {
return {
type: ADD_TODO,
payload,
};
};
/* todo의 id를 입력받아, 일치하는 todolist를 삭제 */
export const removeTodo = (payload) => {
return {
type: REMOVE_TODO,
payload,
};
};
/* todo의 id를 입력받아, 일치하는 todo 아이템의 isDone을 반대로 변경 */
export const switchTodo = (payload) => {
return {
type: SWITCH_TODO,
payload,
};
};
/* 초기화 메서드, 즉 외부 DB로 부터 가져온 todo의 <List> */
export const initialization = (payload) => {
return {
type: INITIALIZATION,
payload,
};
};
// initial states
const initialState = [];
// reducers
const todos = (state = initialState, action) => {
switch (action.type) {
case INITIALIZATION:
return action.payload;
case ADD_TODO: // 기존의 배열에 입력받은 객체를 더함
return [...state, action.payload];
case REMOVE_TODO: // 기존의 배열에서 입력받은 id의 객체를 제거(filter)
return state.filter((item) => item.id !== action.payload);
case SWITCH_TODO: // 기존의 배열에서 입력받은 id에 해당하는 것만 isDone을 반대로 변경(아니면 그대로 반환)
return state.map((item) => {
if (item.id === action.payload) {
return { ...item, isDone: !item.isDone };
} else {
return item;
}
});
default:
return state;
}
};
// export
export default todos;
3.2 TodoList 컴포넌트 변경
3.2.1 데이터 초기화, 조회 변경
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { StyledDiv, StyledTodoListHeader, StyledTodoListBox } from "./styles";
import Todo from "../Todo";
import axios from "axios";
import { initialization } from "../../modules/todos";
/* 컴포넌트 개요 : 메인 > TODOLIST. 할 일의 목록을 가지고 있는 컴포넌트 */
function TodoList({ isActive }) {
const dispatch = useDispatch();
useEffect(() => {
const getTodos = async () => {
try {
const { data } = await axios.get("http://localhost:4000/todos");
dispatch(initialization(data));
} catch (error) {
console.error(error);
}
};
getTodos();
}, []);
const todos = useSelector((state) => state.todos);
return (
<StyledDiv>
<StyledTodoListHeader>
{isActive ? "해야 할 일 ⛱" : "완료한 일 ✅"}
</StyledTodoListHeader>
<StyledTodoListBox>
{todos
.filter((item) => item.isDone === !isActive)
.map((item) => {
return <Todo key={item.id} todo={item} isActive={isActive} />;
})}
</StyledTodoListBox>
</StyledDiv>
);
}
export default TodoList;
3.2.2 switch, delete 변경
import React from "react";
import { useDispatch } from "react-redux";
import HeightBox from "../common/HeightBox";
import { removeTodo, switchTodo } from "../../modules/todos";
import { useNavigate } from "react-router-dom";
import {
StyledDiv,
StyledTitle,
StyledContents,
TodoButton,
FlexButtonBox,
LinkedP,
FlexTitleBox,
} from "./styles";
import axios from "axios";
/* 메인 > TODOLIST > TODO. 할 일의 단위 컴포넌트 */
function Todo({ todo, isActive }) {
// 삭제 확인 용 메시지 관리
const CONFIRM_MESSAGE = `[삭제 확인]\n\n"${todo.title}" 항목을 정말로 삭제하시겠습니까?\n삭제를 원치 않으시면 [취소] 버튼을 눌러주세요.`;
// hooks
const dispatch = useDispatch();
const navigate = useNavigate();
// 완료, 취소를 handling하는 함수
const handleSwitchButton = async () => {
// 서버 통신 먼저!
try {
await axios.patch(`http://localhost:4000/todos/${todo.id}`, {
isDone: !todo.isDone,
});
dispatch(switchTodo(todo.id));
} catch (error) {
alert("서버 통신 중 에러가 발생했습니다. 다시 시도해주세요.");
}
};
// [삭제] 버튼 선택 시 호출되는 함수(user의 confirmation 필요)
const handleRemoveButton = async () => {
if (window.confirm(CONFIRM_MESSAGE)) {
try {
// 서버 통신 먼저!!
await axios.delete(`http://localhost:4000/todos/${todo.id}`);
dispatch(removeTodo(todo.id));
} catch (error) {
alert("서버 통신 중 에러가 발생했습니다. 다시 시도해주세요.");
console.error(error);
}
}
};
// [상세보기]를 선택하는 경우 이동하는 함수
const handleDetailPageLinkClick = () => {
navigate(`/${todo.id}`);
};
return (
<StyledDiv>
<FlexTitleBox>
<StyledTitle>{todo.title}</StyledTitle>
<LinkedP onClick={handleDetailPageLinkClick}>[상세보기]</LinkedP>
</FlexTitleBox>
<HeightBox height={10} />
<StyledContents>{todo.contents}</StyledContents>
<HeightBox height={20} />
<FlexButtonBox>
<TodoButton onClick={handleSwitchButton}>
{isActive ? "완료" : "취소"}
</TodoButton>
<TodoButton onClick={handleRemoveButton}>삭제</TodoButton>
</FlexButtonBox>
</StyledDiv>
);
}
export default Todo;
3.2.3 Input 컴포넌트 변경
import React, { useState } from "react";
import LabledInput from "../common/LabledInput";
import HeightBox from "../common/HeightBox";
import { StyledButton } from "./styles";
import { FlexDiv } from "./styles";
import RightMarginBox from "../common/RightMarginBox";
import "./styles";
import { StyledDiv } from "./styles";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import { addTodo } from "../../modules/todos";
import axios from "axios";
/* Todo 메인 페이지에서 제목과 내용을 입력하는 영역 */
function Input() {
const dispatch = useDispatch();
// useSelector를 통한, store의 값 접근
const todos = useSelector((state) => state.todos);
// 컴포넌트 내부에서 사용할 state 2개(제목, 내용) 정의
const [title, setTitle] = useState("");
const [contents, setContents] = useState("");
// 에러 메시지 발생 함수
const getErrorMsg = (errorCode, params) => {
switch (errorCode) {
case "01":
return alert(
`[필수 입력 값 검증 실패 안내]\n\n제목과 내용은 모두 입력돼야 합니다. 입력값을 확인해주세요.\n입력된 값(제목 : '${params.title}', 내용 : '${params.contents}')`
);
case "02":
return alert(
`[내용 중복 안내]\n\n입력하신 제목('${params.title}')및 내용('${params.contents}')과 일치하는 TODO는 이미 TODO LIST에 등록되어 있습니다.\n기 등록한 TODO ITEM의 수정을 원하시면 해당 아이템의 [상세보기]-[수정]을 이용해주세요.`
);
default:
return `시스템 내부 오류가 발생하였습니다. 고객센터로 연락주세요.`;
}
};
// title의 변경을 감지하는 함수
const handleTitleChange = (event) => {
setTitle(event.target.value);
};
// contents의 변경을 감지하는 함수
const handleContentsChange = (event) => {
setContents(event.target.value);
};
// form 태그 내부에서의 submit이 실행된 경우 호출되는 함수
const handleSubmitButtonClick = async (event) => {
// submit의 고유 기능인, 새로고침(refresh)을 막아주는 역함
event.preventDefault();
// 제목과 내용이 모두 존재해야만 정상처리(하나라도 없는 경우 오류 발생)
// "01" : 필수 입력값 검증 실패 안내
if (!title || !contents) {
return getErrorMsg("01", { title, contents });
}
// 이미 존재하는 todo 항목이면 오류
const validationArr = todos.filter(
(item) => item.title === title && item.contents === contents
);
// "02" : 내용 중복 안내
if (validationArr.length > 0) {
return getErrorMsg("02", { title, contents });
}
// 추가하려는 todo를 newTodo라는 객체로 새로 만듦
const newTodo = {
title,
contents,
isDone: false,
};
try {
const resposne = await axios.post("http://localhost:4000/todos", newTodo);
// 서버에서 받은 id를 추가
const newId = resposne.data.id;
// todo를 추가하는 reducer 호출
// 인자 : payload
dispatch(addTodo({ ...newTodo, id: newId }));
// state 두 개를 초기화
setTitle("");
setContents("");
} catch (error) {
alert("서버 통신 중 에러가 발생했습니다. 다시 시도해주세요.");
console.error(error);
}
};
return (
<StyledDiv>
<form onSubmit={handleSubmitButtonClick}>
<FlexDiv>
<RightMarginBox margin={10}>
<LabledInput
id="title"
label="제목"
placeholder="제목을 입력해주세요."
value={title}
onChange={handleTitleChange}
/>
<HeightBox height={10} />
<LabledInput
id="contents"
label="내용"
placeholder="내용을 입력해주세요."
value={contents}
onChange={handleContentsChange}
/>
</RightMarginBox>
<StyledButton type="submit">제출</StyledButton>
</FlexDiv>
</form>
</StyledDiv>
);
}
export default Input;
일반적으로는 서버가 id를 생성하고, 클라이언트에게 반환해주는 방식이 더 안전하고 일관성이 있다.
댓글남기기