TodoList_Lv2
Lv2 과제의 조건은 react-router-dom, styled-components, redux를 사용해서 TodoList를 제작하는 것이다.
구현해야 될 기능은 다음과 같다.
- Create Todo
- Read Todos, Todo
- Update Todo
- Delete Todo
요구사항은 다음과 같다.
- todos 데이터는 리덕스를 사용해서 전역으로 상태를 관리한다.
- todos 모듈은 Ducks 패턴으로 구현한다.
- Todo를 추가하면 제목 input과 내용 input은 다시 빈 값이 되도록 구현한다.
- input에 값이 있는 상태에서 상세페이지로 이동하는 경우, input의 value가 초기화 되도록 구현한다.
- Todo의 완료상태가 true이면, 상태 버튼의 라벨을 “취소”, false 이면 라벨을 “완료” 로 보이도록 구현한다.
- 상세보기 클릭하면 Todo의 상세 페이지로 이동한다.
- map을 사용할 때 반드시 key을 넣어야 하며, map 의 index를 사용을 금지한다.
- Todo Id 생성 시 todos.length 사용해서 생성하지 않는다. todos.length 을 사용해서 id 생성 시 발생할 수 있는 문제점에 대해 고민해보자.
상세 페이지에서 보여야 하는 요소들은 다음과 같다.
- Todo의 ID
- Todo의 제목
- Todo의 내용
- 이전으로 버튼
- 이전으로 버튼을 구현하고, 이전으로 버튼을 클릭하면 리스트 화면으로 되돌아 간다.
먼저 react-router-dom, styled-components, redux를 사용해야 하기 때문에 플러그인 설치를 해준다.
1. react-router-dom 설치
React Router Dom - 소개, hooks, children
# react-router-dom이란? 1. 페이지를 구현할 수 있게 해주는 패키지 react-router-dom을 사용하면 여러 페이지를 가진 웹을 만들 수 있게 된다. # react-router-dom 설치하기 1. 패키지 설치 yarn add react-router-dom #
junhub.tistory.com
2. styled-components 설치
yarn add styled-components
3. redux 설치
Redux - Redux 설정
# 리덕스 설치 리액트에서 리덕스를 사용하기 위해서는 2개의 패키지를 설치해야 한다. vscode 터미널에서 아래 명령어를 입력해서 2개의 패키지를 설치하면 된다. 참고로 react-redux 라는 패키지는
junhub.tistory.com
폴더 구조는 다음과 같다.
라우터 설정을 위해 shared 폴더 내에 Router.js 에 다음과 같이 코드를 구성한다.
Home 컴포넌트는 위와 같다. 구역별로 컴포넌트를 분리한 모습이다.
순서대로 컴포넌트를 살펴보자.
TitleArea.jsx
Title 구역을 컴포넌트화해서 분리했다. 실제 페이지에 렌더링되는 부분은 다음과 같다.
AddForm.jsx
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import { add_title } from '../redux/modules/todos'
function AddForm() {
const todos = useSelector((state) => {
return state.todos.todos
})
const [title, setTitle] = useState('')
const [comment, setComment] = useState('')
const dispatch = useDispatch()
const addButton = (event) => {
event.preventDefault();
if (title === '' || comment === '') {
return;
}
const uniqueId = todos.length === 0 ? 1 : Math.max(...todos.map(item => item.id)) + 1;
dispatch(
add_title({
id: uniqueId,
title: title,
comment : comment,
isDone : false
})
)
setTitle('')
setComment('')
}
return (
<StInput_Area>
<StInput_group>
<form onSubmit={addButton}>
<StLabel_title>제목</StLabel_title>
<StInput value={title} onChange={(event) => {
setTitle(event.target.value)
}}></StInput>
<StLabel_comment>내용</StLabel_comment>
<StInput value={comment} onChange={(event) => {
setComment(event.target.value)
}}></StInput>
<StAdd_button>추가하기</StAdd_button>
</form>
</StInput_group>
</StInput_Area>
)
}
export default AddForm
const StInput_Area = styled.div`
padding: 30px;
display: flex;
background-color: #e9e9e9;
border-radius: 10px;
align-items: center;
margin: 0 auto;
justify-content: space-between;
gap: 20px;
margin-top: 20px;
`
const StInput_group = styled.div`
align-items: center;
display: flex;
gap: 20px;
`
const StLabel_title = styled.label`
font-weight: 700;
`
const StInput = styled.input`
width: 240px;
height: 40px;
padding: 0 12px;
border-radius: 12px;
border: none;
margin-left: 20px;
`
const StLabel_comment = styled.label`
font-weight: 700;
margin-left: 20px;
`
const StAdd_button = styled.button`
background-color: #006b80;
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
width: 140px;
height: 40px;
cursor: pointer;
margin-left: 500px;
`
구현한 기능에 대해서는 아래에서 따로 다루겠다.
TodoContainer.jsx
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { complete_todo, delete_todo } from '../redux/modules/todos'
function TodoContainer() {
const todos = useSelector((state) => {
return state.todos.todos
})
const dispatch = useDispatch()
const deleteButton = (id) => {
const updateTodo = todos.filter((item)=>{
return item.id !== id
})
// console.log(updateTodo)
dispatch(delete_todo(updateTodo))
}
const completeButton = (id) => {
const updateTodo = todos.map((item)=>{
if(item.id === id){
return{
...item,
isDone : true
}
}else{
return item
}
})
dispatch(complete_todo(updateTodo))
}
return (
<StList_container>
<StWorking>Working.. 🔥</StWorking>
<StContain>
{
todos.filter((item) => {
return item.isDone === false
}).map((item) => {
return (
<StWorking_card key={item.id}>
<Link to={`/${item.id}`} style={{ textDecorationLine: 'none' }}>상세보기</Link>
<StWorking_card_title>{item.title}</StWorking_card_title>
<StWorking_card_comment>{item.comment}</StWorking_card_comment>
<StWorking_button>
<StDelete_button onClick={() => deleteButton(item.id)}>삭제하기</StDelete_button>
<StComplete_button onClick={() => completeButton(item.id)}>완료!</StComplete_button>
</StWorking_button>
</StWorking_card>
)
})
}
</StContain>
</StList_container>
)
}
export default TodoContainer
const StList_container = styled.div`
padding: 0 24px;
margin-top: 20px;
`
const StWorking = styled.div`
font-weight: 700;
font-size: 27px;
padding: 10px;
`
const StContain = styled.div`
display: flex;
flex-wrap: wrap;
gap : 12px;
`
const StWorking_card = styled.div`
border: 4px solid teal;
border-radius: 12px;
padding: 12px 24px 24px;
width: 270px;
`
const StWorking_card_title = styled.div`
display: block;
font-size: 1.5em;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
margin-inline-start: 0px;
margin-inline-end: 0px;
font-weight: bold;
`
const StWorking_card_comment = styled.div`
font-size: 16px;
`
const StWorking_button = styled.div`
display: flex;
gap: 10px;
margin-top: 24px;
`
const StDelete_button = styled.div`
background-color: #fff;
border: 2px solid red;
border-radius: 8px;
height: 40px;
width: 50%;
cursor: pointer;
display: flex;
justify-content: center; /* 가로 방향 가운데 정렬 */
align-items: center; /* 세로 방향 가운데 정렬 */
`
const StComplete_button = styled.div`
background-color: #fff;
border: 2px solid green;
border-radius: 8px;
cursor: pointer;
height: 40px;
width: 50%;
display: flex;
justify-content: center; /* 가로 방향 가운데 정렬 */
align-items: center; /* 세로 방향 방향 가운데 정렬 */
`
TodoNotDone.jsx
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { cancel_todo, delete_todo } from '../redux/modules/todos'
function NotDoneContainer() {
const todos = useSelector((state) => {
return state.todos.todos
})
const dispatch = useDispatch()
const deleteButton = (id)=>{
const updateTodo = todos.filter((item)=>{
return item.id !== id
})
dispatch(delete_todo(updateTodo))
}
const cancelButton = (id)=>{
const updateTodo = todos.map((item)=>{
if(item.id === id){
return{
...item,
isDone : false
}
}else{
return item
}
})
dispatch(cancel_todo(updateTodo))
}
return (
<StList_container>
<StWorking>Done..!🎉</StWorking>
<StContain>
{
todos.filter((item)=>{
return item.isDone === true
}).map((item) => {
return (
<StWorking_card key={item.id}>
<Link to={`/${item.id}`} style={{ textDecorationLine: 'none' }}>상세보기</Link>
<StWorking_card_title>{item.title}</StWorking_card_title>
<StWorking_card_comment>{item.comment}</StWorking_card_comment>
<StWorking_button>
<StDelete_button onClick={()=>{deleteButton(item.id)}}>삭제하기</StDelete_button>
<StComplete_button onClick={()=>{cancelButton(item.id)}}>취소!</StComplete_button>
</StWorking_button>
</StWorking_card>
)
})
}
</StContain>
</StList_container>
)
}
export default NotDoneContainer
const StList_container = styled.div`
padding: 0 24px;
margin-top: 50px;
`
const StWorking = styled.div`
font-weight: 700;
font-size: 27px;
padding: 10px;
`
const StContain = styled.div`
display: flex;
flex-wrap: wrap;
gap : 12px;
`
const StWorking_card = styled.div`
border: 4px solid teal;
border-radius: 12px;
padding: 12px 24px 24px;
width: 270px;
`
const StWorking_card_title = styled.div`
display: block;
font-size: 1.5em;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
margin-inline-start: 0px;
margin-inline-end: 0px;
font-weight: bold;
`
const StWorking_card_comment = styled.div`
font-size: 16px;
`
const StWorking_button = styled.div`
display: flex;
gap: 10px;
margin-top: 24px;
`
const StDelete_button = styled.div`
background-color: #fff;
border: 2px solid red;
border-radius: 8px;
height: 40px;
width: 50%;
cursor: pointer;
display: flex;
justify-content: center; /* 가로 방향 가운데 정렬 */
align-items: center; /* 세로 방향 가운데 정렬 */
`
const StComplete_button = styled.div`
background-color: #fff;
border: 2px solid green;
border-radius: 8px;
cursor: pointer;
height: 40px;
width: 50%;
display: flex;
justify-content: center; /* 가로 방향 가운데 정렬 */
align-items: center; /* 세로 방향 방향 가운데 정렬 */
`
Details.jsx, 상세보기 버튼을 눌렀을 때 렌더링되는 컴포넌트이다.
import React from 'react'
import { useSelector } from 'react-redux'
import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components'
function Details() {
const todos = useSelector((state)=>{
return state.todos.todos
})
const navigate = useNavigate()
const params = useParams()
const foundData = todos.find((item)=>{
return item.id === parseInt(params.id)
})
console.log(todos)
console.log(foundData)
console.log(params)
return (
<StPage>
<StContainer>
<StBox>
<StHeader>
<div>ID : {foundData.id}</div>
<StBack_Button onClick={()=>{
navigate('/')
}}>이전으로</StBack_Button>
</StHeader>
<StTitle>{foundData.title}</StTitle>
<StComment>{foundData.comment}</StComment>
</StBox>
</StContainer>
</StPage>
)
}
export default Details
const StPage = styled.div`
display: block;
`
const StContainer = styled.div`
border: 2px solid rgb(238, 238, 238);
width: 100%;
height: 100vh;
display: flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
`
const StBox = styled.div`
width: 600px;
height: 400px;
border: 1px solid rgb(238, 238, 238);
display: flex;
flex-direction: column;
-webkit-box-pack: justify;
justify-content: space-between;
`
const StHeader = styled.div`
display: flex;
height: 80px;
-webkit-box-pack: justify;
justify-content: space-between;
padding: 0px 24px;
-webkit-box-align: center;
align-items: center;
`
const StBack_Button = styled.div`
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgb(221, 221, 221);
height: 40px;
width: 120px;
background-color: rgb(255, 255, 255);
border-radius: 12px;
cursor: pointer;
`
const StTitle = styled.div`
display: block;
font-size: 2em;
margin-block-start: 0.67em;
margin-block-end: 0.67em;
margin-inline-start: 0px;
margin-inline-end: 0px;
font-weight: bold;
padding: 0px 24px;
`
const StComment = styled.div`
padding: 0px 24px;
margin-bottom: 200px;
`
/modules/todos.js
const ADD_TITLE = 'ADD_TITLE'
const COMPLETE_TODO = 'COMPLETE_TODO'
const DELETE_TODO = 'DELETE_TODO'
const CANCEL_TODO = 'CANCEL_TODO'
export const add_title = (payload)=>{
return {type : ADD_TITLE, payload : payload}
}
export const complete_todo = (payload) => {
return {type : COMPLETE_TODO, payload : payload}
}
export const delete_todo = (payload) => {
return {type : DELETE_TODO, payload : payload}
}
export const cancel_todo = (payload) => {
return {type : CANCEL_TODO, payload : payload}
}
const initialState = {
todos :[
{ id: 1, title: '리액트', comment : '리액트 마스터하기', isDone : false },
{ id: 2, title: '리덕스', comment : '리덕스 공부하기', isDone : false}
]
}
const todos = (state = initialState, action) => {
switch (action.type) {
case ADD_TITLE:
return{
...state,
todos : [...state.todos, action.payload]
}
case DELETE_TODO:
// console.log(action.payload)
return{
...state,
todos : action.payload
}
case COMPLETE_TODO:
// console.log(action.payload)
return{
...state,
todos : action.payload
}
case CANCEL_TODO:
// console.log(action.payload)
return{
...state,
todos : action.payload
}
default:
return state;
}
};
export default todos;
/config/configStores.js
import { createStore } from "redux";
import { combineReducers } from "redux";
import todos from '../modules/todos'
const rootReducer = combineReducers({
todos : todos
});
const store = createStore(rootReducer);
export default store;
# state값 받아와서 렌더링하기
modules 폴더 내에 있는 todos.js 파일에 위와 같이 초기값을 설정한다.
todos.js 파일 내에 있는 리듀서 부분이다. 리듀서는 해당 case에 맞게 action.payload 값으로 값을 변경시켜서 리턴해준다고 보면 된다.
지금은 초기값을 받아오는 부분이므로 default에 해당되어 단순 state가 리턴된다고 이해하면 된다.
config 폴더 내에 있는 configStore.js 파일이다. combineReducers()를 통해 모든 state를 합쳐주고 rootReducer에 할당해준다.
그 후 createStore(rootReducer)를 store에 할당하고 export 해준다.
화면을 렌더링해주는 부분인 TodoContainer.jsx 부분이다. useSelector()로 state를 받아와서 state.todos.todos 값을 todos에 할당해준다.
todos를 console을 찍어보면 다음과 같다.
만약 title에 접근하고 싶다면 todos.title 과 같은 형태로 사용하면 된다.
화면이 렌더링되는 부분이다. todos.filter를 사용해서 todos 배열 내의 요소를 하나씩 item에 할당시키면서 반복을 돌리는데,
이때 item.isDone이 false인 것만 리턴하게 된다. 그 이유는 현재 렌더링되는 부분이 Working 파트 즉, 아직 완료를 못한 카드만 렌더링하는 부분이기 때문이다.
Done 파트, 완료를 한 카드를 렌더링하려면 위와 반대로 item.isDone === true 로 바꿔주면 완료한 todo 카드만 렌더링이 된다.
# 라우터 설정
shared 폴더 내에 있는 Router.js 파일이다.
동적 라우팅을 사용하여 path="/:id" element={<Details />}와 같이 코드를 구성했는데,
아무것도 입력하지 않았을때는 자동으로 Home 컴포넌트가 렌더링 되게 했다.
path="/:id"의 의미는 동적라우팅을 사용한 것인데, 홈 화면('/') 에서 어떤 값을 입력해도 Details 컴포넌트가 렌더링 되게 했다.
이렇게 구성한 이유는 뒤에서 설명하겠다.
App.jsx 파일이다. 자동으로 Router를 리턴하게 구성되어 있다.
Home.jsx 컴포넌트이다. 컴포넌트를 구역별로 분리시켜 구성했다.
TodoContainer.jsx 컴포넌트이다. 화면이 렌더링 되는 부분인데, Link API를 사용해 상세보기를 누르면 /${item.id}로 가게했다.
다시 Router.js 부분을 보면
동적라우팅을 사용해서 '/' 뒤에 어떤 값을 넣어도 Details 컴포넌트로 이동하게 했다.
import React from 'react'
import { useSelector } from 'react-redux'
import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components'
function Details() {
const todos = useSelector((state)=>{
return state.todos.todos
})
const navigate = useNavigate()
const params = useParams()
const foundData = todos.find((item)=>{
return item.id === parseInt(params.id)
})
console.log(todos)
console.log(foundData)
console.log(params)
return (
<StPage>
<StContainer>
<StBox>
<StHeader>
<div>ID : {foundData.id}</div>
<StBack_Button onClick={()=>{
navigate('/')
}}>이전으로</StBack_Button>
</StHeader>
<StTitle>{foundData.title}</StTitle>
<StComment>{foundData.comment}</StComment>
</StBox>
</StContainer>
</StPage>
)
}
export default Details
Details.jsx 컴포넌트 부분이다. 여기서 useParms() 라는 훅이 나오는데, 해당 페이지의 정보를 알려주는 훅이다.
console을 찍으면 다음과 같이 출력된다.
즉, 해당 todo 카드의 아이디로 이동(/{item.id}) 하고, useSelector()로 받아온 todos의 요소들 중에서 id가 같은 요소를 찾아내
foundData에 할당하고 ID : {foundData.id} 와 같은 형태로 렌더링 하는 것이다.
위와 같은 과정으로 상세보기를 누른 카드의 정보가 상세보기 페이지에서도 똑같이 렌더링이 되는 것이다.
또, useNavigate()라는 훅을 사용해서 버튼을 누르면 navigate('/'), Home 컴포넌트로 이동하게 했다.
# todo 추가 기능
제목과 내용을 useState로 관리하기 위한 코드이다.
onChange() 이벤트를 사용해서 setTitle(event.target.value)로 title 값을 조작하게 했다.
여기서 event.target.value는 입력한 값을 의미한다. 즉, 입력한 값을 setTitle을 통해 title에 할당해준 것이라고 봐도 무방하다.
form 태그로 입력되는 부분, 버튼을 감싸서 onSubmit 이벤트를 사용해 addButton 함수를 연결시켜줬다.
event.preventDefault()는 이벤트의 기본 동작을 취소하는 메소드이다.
form 요소에서 onSubmit 이벤트가 발생하면, 기본적으로 브라우저는 해당 form 요소의 action 속성 값으로 지정된 URL로 데이터를 전송하고, 페이지를 다시 로드한다.
하지만 위 코드에서는 form 요소가 서버로 데이터를 전송하는 것이 아니라, addButton 함수에서 상태값을 업데이트하고 있다.
따라서 event.preventDefault()를 호출하여 브라우저가 기본적으로 수행하는 동작을 취소하고, 페이지가 새로고침되지 않도록 한다.
그리고 조건문을 사용해 만약 title 혹은 comment의 값이 빈값이라면 아무것도 동작하지 않게 했고,
dispatch에 할당한 useDispatch() 훅을 사용해 todos.js로 정보를 보내게 했다.
useDispatch() 훅은 액션 객체를 리듀서로 보내주는 기능을 한다.
add_title은 todos.js 에서 만든 Action Creator이다. 내용은 다음과 같다.
액션 객체는 type과 payload 두 데이터가 포함되어있는데, type은 리듀서에서 조건에 맞게 state를 변환하기 위해 사용하는 것이고, payload는 변환하고 싶은 형태를 보내는 것이다.
변환하고 싶은 형태 부분을 보면 다음과 같다.
title은 onChange와 setTitle로 인해 현재 입력된 제목을 할당, comment도 마찬가지, isDone은 일단 todo를 추가하자마자는 Working, 현재 진행중인 파트에 렌더링 되야하므로 false를 value 값으로 지정했다.
id 는 uniqueId를 value로 넣었는데, 만약 useSelector()로 받아온 초기 state의 배열 길이가 0이라면 즉, 값이 없다면 1를 리턴하고,
그게 아니라면 todos를 map으로 돌면서 id중에 가장 큰 값을 골라내서 그 값에 + 1 값을 uniqueId에 할당하게 했다.
이렇게 한 이유는 단순 todos.length + 1을 하게되면 문제가 생기기 때문인데, 만약 id : 1 , id : 2 , id : 3 인 todo 카드 세개가 있다고 하자.
id : 2인 카드를 지우게 되면 id : 1, id : 3인 카드 두개가 남게 되고 todos.length는 2가 되므로 새로운 카드를 추가하면 id : 3인 카드가 하나 더 추가된다.
이렇게 되면 아이디가 같아지므로 상세보기 페이지에서도 문제가 생기게되고, 완료하기 및 취소하기 버튼도 id를 식별해서 작동하는 로직이므로 문제가 생기게 된다.
따라서 단순하게 배열 요소 개수에 +1 하는 것이 아닌, 실제 배열 요소의 아이디 중 가장 큰 값을 불러와 + 1 한 값을 새롭게 저장한 카드의 id로 할당하게되면 위의 문제가 해결되는 것이다.
배열에 담고 있는데 기존 state의 todos를 스프레드 문법을 이용해 닫고, action.payload 즉, 위에서 dispatch를 통해 담아온 값을 추가해주는 것이다.
이때 add_title, Action Creater를 사용해서 ADD_TITLE 조건에 걸리게 한 것이다.
# todo 삭제 기능
삭제하기 버튼을 누르면 filter()를 사용해 해당 todo 카드의 id를 매개변수로 받아와서 해당 카드의 아이디와 일치하지 않는 카드만 리턴하게 했다. 즉, 버튼을 누른 해당 카드만 제외한 값을 updateTodo에 할당해서 dispatch를 통해 액션객체를 리듀서로 보내준다.
여기서 중요하게 봐야할 점은, ADD_TITLE은 기존 값에 action.payload를 추가하기 위해 배열로 감싸서 기존 state.todos를 스프레드 문법으로 펼쳤고, DELETE_TODO는 삭제한 값인 updateTodo 만을 리턴해야 하므로 위와 같이 코드를 구성했다.