오늘부터 개인 프로젝트를 진행하면서 구현하는 기능이나 에러 해결 등 도움이 될만한 것들을 글로 적어 기록해보려고 한다.
그 첫 번째 시간으로 Todo List를 간단하게 구현해보자.
CSS 없이 기능만을 구현하는 것이 이번 글의 목표이기 때문에 디자인은 프로젝트 CSS를 전체적으로 만질 때 다룰 예정이다.
디자인 보기 힘들어도 이해해주세요..😂
- 프로젝트 구조
📦components
┗ 📂todo
┣ 📜TodoInput.tsx
┣ 📜TodoEdit.tsx
┣ 📜TodoItem.tsx
┣ 📜TodoList.tsx
┗ 📜Todos.tsx
📦pages
┣ 📜_app.tsx
┗ 📜index.tsx
📦redux
┣ 📂slice
┃ ┗ 📜todoListSlice.ts
┣ 📜hooks.ts
┗ 📜store.ts
📦styles
┗ 📜GlobalStyles.ts
간단하게 작업하려 했는데 Redux로 전역 상태를 관리하려다 보니 생각보다 복잡해졌다. 😅
- 라이브러리
$ npm i styled-components
$ npm i react-icons
$ npm i react-redux @reduxjs/toolkit
- 구현 기능
- todo 추가
- todo 삭제
- todo 수정
- todo 완료 체크박스 및
취소선
- 코드
1-1. _app.tsx
import type { AppProps } from "next/app";
import GlobalStyles from "../styles/GlobalStyles";
import { Provider } from "react-redux";
import store from "redux/store";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<GlobalStyles />
<Provider store={store}>
<Component {...pageProps} />
</Provider>
</>
);
}
- Redux의 store를 사용하여 전역 상태를 관리할 것이기 때문에 Provider 컴포넌트로 모든 페이지에 store를 연동시킨다.
1-2. index.tsx
import Todos from "components/todo/Todos";
const Todo = () => {
return (
<>
<Todos />
</>
);
};
export default Todo;
- Todos 컴포넌트를 불러온다. Next.js에서는 index.tsx가 홈 화면이라고 보면 된다.
2. Redux-Toolkit
2-1. store.ts
import { configureStore } from "@reduxjs/toolkit";
import { todoReducer } from "./slice/todoListSlice";
const store = configureStore({
reducer: { todos: todoReducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
- configureStore 함수로 todoReducer를 reducer로 가지고 있는 store를 만든다.
- typeof를 통해 자체적으로 store.getState와 store.dispatch의 type을 각각 추출하여 RootState, AppDispatch type으로 선언 후 export 한다.
2-2. todoListSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface TodoState {
id: number;
text: string;
done: boolean;
edit: boolean;
}
// state 초기값 설정
const initialState: TodoState[] = [];
// createSlice 함수를 사용하여 todo 리스트 추가, 삭제, 수정 등의 역할을 하는 reducer를 만든다.
// reducer : 데이터(state)를 수정하는 함수이다.
const todoSlice = createSlice({
name: "todos",
initialState,
reducers: {
// 1. todo를 추가하는 함수
addTodo(state, action: PayloadAction<TodoState>) {
state.push(action.payload);
},
// 2. todo를 수정하는 함수
editTodo(state, action: PayloadAction<TodoState>) {
state.map((todo) => {
// 여기서 action.payload로 받은 오브젝트의 key 값에 바로 접근할 수 있는 이유는
// type을 TodoState로 선언해 두었기 때문이다.
// 해당 타입의 key 값인 id뿐만 아니라 text, done, edit 에도 접근할 수 있다.
if (todo.id === action.payload.id) {
todo.text = action.payload.text;
}
});
},
// 3. todo를 삭제하는 함수
delTodo(state, action: PayloadAction<number>) {
return (state = state.filter((todo) => todo.id !== action.payload));
},
// 4. 수정 여부를 판단하는 함수
toggleTodoEdit(state, action: PayloadAction<number>) {
state.map((todo) => {
if (todo.id === action.payload) {
todo.edit = !todo.edit;
}
});
},
// 5. 체크박스 toggle 함수
toggleTodoDone(state, action: PayloadAction<number>) {
state.map((todo) => {
if (todo.id === action.payload) {
todo.done = !todo.done;
}
});
},
},
});
// action : reducer와 소통하기 위한 방법이다.
export const { addTodo, editTodo, delTodo, toggleTodoDone, toggleTodoEdit } =
todoSlice.actions;
// store에 넣어줄 reducer를 export 한다.
export const todoReducer = todoSlice.reducer;
1. addTodo
- array.push 함수로 현재 todo 리스트에 새로운 todo 추가
2. editTodo
- 수정 아이콘 클릭 시 id가 일치하는 todo의 내용 수정
3. delTodo
- 삭제 아이콘 클릭 시 id가 일치하는 todo의 내용 삭제
4. toggleTodoEdit
- 수정 아이콘 클릭 시 해당 todo를 수정 모드로 만들기 위한 toggle 함수
5. toggleTodoDone
- 체크박스 클릭 시 선택 또는 해제 기능을 구현하기 위한 toggle 함수
2-3. hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "redux/store";
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
- useSelector와 useDispatch를 type을 지정한 hook으로 만들어 사용한다.
3. Todo Components
3-1. Todos.tsx
import TodoInsert from "./TodoInsert";
import TodoList from "./TodoList";
const Todos = () => {
return (
<div className="Todos">
<h1>Todo List</h1>
<TodoInsert />
<TodoList />
</div>
);
};
export default Todos;
- todo 입력창인 TodoInsert 컴포넌트와 todo 리스트를 불러와주는 TodoList 컴포넌트를 불러온다.
3-2. TodoInsert.tsx
import { useRef, useState } from "react";
import { useAppDispatch } from "redux/hooks";
import { addTodo } from "redux/slice/todoListSlice";
const TodoInsert = () => {
// todoId : id 초기값 지정
const todoId = useRef(0);
// textRef : input document에 접근하여 사용자가 입력하는 todo 정보를 받아온다.
const textRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState<string>("");
// dispatch : reducer에게 action을 보내는 역할을 한다.
const dispatch = useAppDispatch();
const changeInput = (e: any) => {
const {
target: { value },
} = e;
setText(value);
};
const onSubmit = (e: any) => {
// 페에지 새로고침 방지
e.preventDefault();
// 공백일 때 입력 방지
if (!text) return;
// todo 추가
dispatch(
addTodo({
id: todoId.current++,
text: text,
done: false,
edit: false,
})
);
setText("");
// 내용 입력 후에도 input창에 focus를 두게 한다.
textRef.current?.focus();
};
return (
<form onSubmit={onSubmit}>
<input type="text" value={text} onChange={changeInput} ref={textRef} />
</form>
);
};
export default TodoInsert;
- 사용자가 todo 내용 입력 후 엔터를 누르면 form에서 submit을 감지하여 todo state에 데이터를 추가해주는 컴포넌트이다.
3-3. TodoList.tsx
import { useAppSelector } from "redux/hooks";
import TodoItem from "./TodoItem";
const TodoList = () => {
// useSelector : store의 state 데이터를 가져온다.
const todos = useAppSelector((state) => state.todos);
return (
<ul className="TodoList">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
};
export default TodoList;
- map 함수를 통해 배열 각각의 요소에 접근하여 TodoItem 컴포넌트에 데이터를 props로 전송해준다.
※ map 함수 사용 시 주의할 점은 항상 최상단(부모) 태그에 key 값을 입력해줘야 한다는 것이다. 또한 key 값은 고유해야 하기 때문에 index를 key 값으로 부여하는 것은 다른 key 값과 중복될 수 있기 때문에 지양해야 한다.
3-4. TodoItem.tsx
import { FaRegTrashAlt, FaPencilAlt } from "react-icons/fa";
import {
MdOutlineCheckBox,
MdOutlineCheckBoxOutlineBlank,
} from "react-icons/md";
import { useAppDispatch } from "redux/hooks";
import {
delTodo,
TodoState,
toggleTodoDone,
toggleTodoEdit,
} from "redux/slice/todoListSlice";
import TodoEdit from "./TodoEdit";
interface Props {
todo: TodoState;
}
const TodoItem = ({ todo }: Props) => {
const { id, text, done, edit } = todo;
const dispatch = useAppDispatch();
return (
<div>
{edit ? (
<TodoEdit todo={todo} />
) : (
<li className={done ? "on" : ""}>
<span onClick={() => dispatch(toggleTodoDone(id))}>
{done ? <MdOutlineCheckBox /> : <MdOutlineCheckBoxOutlineBlank />}
</span>
<em onClick={() => dispatch(toggleTodoDone(id))}>{text}</em>
<button onClick={() => dispatch(toggleTodoEdit(id))}>
<FaPencilAlt size="15" />
</button>
<button onClick={() => dispatch(delTodo(id))}>
<FaRegTrashAlt color="rgb(175,169,169)" size="15" />
</button>
</li>
)}
</div>
);
};
export default TodoItem;
- done : true => 박스 체크 및 취소선 활성화, false => 박스 체크 해제 및 취소선 비활성화
- edit : true => TodoEdit 컴포넌트 불러오기, false => 체크박스, todo 내용, 수정 아이콘, 삭제 아이콘 활성화
3-5. TodoEdit.tsx
import { useRef, useState, useEffect } from "react";
import { useAppDispatch } from "redux/hooks";
import { editTodo, TodoState, toggleTodoEdit } from "redux/slice/todoListSlice";
interface Props {
todo: TodoState;
}
const TodoEdit = ({ todo }: Props) => {
const textRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState<string>("");
const dispatch = useAppDispatch();
const changeInput = (e: any) => {
const {
target: { value },
} = e;
setText(value);
};
const onSubmit = (e: any) => {
e.preventDefault();
if (!text) return;
// 변경된 text 내용 수정
dispatch(
editTodo({
...todo,
text: text,
})
);
dispatch(toggleTodoEdit(todo.id));
};
// 수정할 text 데이터 불러오기
useEffect(() => {
setText(todo.text);
textRef.current?.focus();
}, [todo.text]);
return (
<form onSubmit={onSubmit}>
<input type="text" value={text} onChange={changeInput} ref={textRef} />
</form>
);
};
export default TodoEdit;
- TodoInsert 컴포넌트와 크게 다르지 않다. useEffect로 수정할 text 데이터를 불러와서 input value에 대입하고, 수정이 완료되면 변경된 text 내용만 수정한다.
4. 취소선
- GlobalStyles.ts
import { createGlobalStyle } from "styled-components";
const GlobalStyles = createGlobalStyle`
.TodoList li.on span {
color: rgb(199, 197, 197);
}
.TodoList li.on em {
color: rgb(199, 197, 197); text-decoration: line-through rgb(159, 127, 231);
}
`;
export default GlobalStyles;
- 현재 나는 styled-components를 사용하고 있어서 생긴 게 좀 다르지만, css로 사용하는 분들은 createGlobalStyle 내부의 코드를 css에 그대로 가져다가 쓰면 된다.
- 마치며
처음 블로그 글을 써봤는데 생각보다 시간이 오래 걸렸다. 글의 내용이 길어진 것도 있지만, 이 글을 보는 분들이 이해할 수 있게 작성하고 싶어서 그런 것 같다. 물론 여기에 있는 코드들과 글은 아직 많이 부족하지만, 계속해서 블로그를 올리다 보면 코딩 실력뿐만 아니라 글쓰기 실력도 오를 것이라고 생각한다.
중요한 건... 꺾이지 않는 마음..!!😆
※
해당 블로그는 개인적으로 작성한 코드를 정리 및 복습을 목적으로 글을 작성하고 있습니다.
글에서 잘못된 부분이나 오타가 있다면 댓글로 알려주세요 :)
부족한 부분이 있더라도 너그럽게 이해해주시면 감사하겠습니다. 🙇♂️
'FE > Next.js' 카테고리의 다른 글
[Next.js] React-DnD with TypeScript (2) | 2023.02.07 |
---|---|
[Next.js] 시계, input 텍스트 입력시 구글에서 검색되게 만들기 (2) | 2022.12.16 |