
이 게시글은 현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) 시리즈중 하나입니다
- Vite 시작하기, TailwindCss로 TodoList UI 구성하기
- React Hook 활용하여 TodoList 기능 개발하기, github page로 배포하기
- MSW로 API Mocking하기, React Query 사용하기
- React Testing Library & Jest로 테스트 하기. CI 적용하기
오늘은 스타일링까지 끝난 투두리스트의 기능을 완성하고 배포까지 해보겠습니다!
1. React Hook으로 비즈니스 로직 관리하기
이전 시간에는 App에서 TodoList의 데이터를 관리했습니다. 이럴 경우 다음과 같은 문제점이 생길 수 있습니다.
- App의 역할이 너무 거대해진다.
- 비즈니스 로직의 재활용이 불가하고 관리가 어렵다.
이러한 상황에서는 React Hook을 사용하여 로직과 관심사를 분리하는 것이 좋습니다.
기존의 App을 보죠, App의 역할은 TodoList를 잘 띄워주는 것인데 타입 정보도 알아야 하고 데이터도 가지고 있으며 추후 추가될 투두리스트 기능까지 더 개발되어야 하는 상황입니다.
import { useState } from "react";
import TodoList from "@components/TodoList";
export interface Todo {
id: string;
content: string;
completed: boolean;
}
export type TabState = "All" | "Active" | "Completed";
function App() {
const [todos, setTodos] = useState<Todo[]>([
{
id: "1",
content: "Todo 1",
completed: false,
},
{
id: "2",
content: "Todo 2",
completed: true,
},
{
id: "3",
content: "Todo 3",
completed: false,
},
]);
const [currentTab, setCurrentTab] = useState<TabState>("All");
return (
<div className="w-screen h-screen flex-col gap-4 flex items-center bg-stone-100">
<h1 className="font-serif font-light text-6xl mt-8 text-stone-400">
todos
</h1>
<TodoList todos={todos} currentTab={currentTab} />
</div>
);
}
export default App;
따라서 다음 Hook을 만들어 줍니다.
src/components/TodoList/useTodoList.ts
import { useState } from "react";
export interface Todo {
id: string;
content: string;
completed: boolean;
}
export type TabState = "All" | "Active" | "Completed";
const useTodoList = () => {
const [todos, setTodos] = useState<Todo[]>([
{
id: "1",
content: "Todo 1",
completed: false,
},
{
id: "2",
content: "Todo 2",
completed: true,
},
{
id: "3",
content: "Todo 3",
completed: false,
},
]);
const [currentTab, setCurrentTab] = useState<TabState>("All");
return {
state: {
todos,
currentTab,
},
};
};
export default useTodoList;
이렇게 하면 굳이 App에서 데이터를 관리할 필요도 없겠네요?
그리고 TodoList에서 바로 todos와 currentTab 값만 사용해 주도록 합시다!
src/components/TodoList/index.tsx
import Header from "./header";
import TodoItem from "./TodoItem";
import Footer from "./footer";
import useTodoList from "./useTodoList";
const TodoList = () => {
const {
state: { todos, currentTab },
} = useTodoList();
return (
<div className="w-[600px] max-h-[calc(100vh-200px)] flex flex-col bg-white rounded-lg drop-shadow-md">
<Header />
<div className="h-full overflow-y-auto">
{todos.map((todo) => (
<>
<TodoItem todo={todo} />
</>
))}
</div>
<Footer todos={todos} currentTab={currentTab} />
</div>
);
};
export default TodoList;
이제 준비는 끝났습니다!
2. 기능 구체화하기
투두리스트에 필요한 기능을 나열해 봅시다! 저는 필요한 기능과 이름까지 미리 작성해 두고 하나하나 처리해 가며 개발하는 것을 좋아합니다!
헤더
헤더에는 다음 action들만 필요하겠네요
action
- toggleTodoAll: 왼쪽 체크 버튼을 누르면 모든 투두를 완료하거나 이미 모두 완료 상태일 경우 모두 미완료 상태로 만듭니다.
- addTodo: content 내용을 string으로 받아 새로운 투두를 만듭니다.
이때 input태그의 입력을 관리하는 핸들러는 해당 컴포넌트에서 다루는 것이 옳습니다.
투두 아이템
투두 아이템에는 state와 action 둘 다 필요합니다.
state로 todos가 필요하겠죠? 그런데 주의해야 할 것은 실제 렌더링 되는 투두들은 currentTab이 무엇이냐에 따라 달라집니다.
이러한 처리를 뷰에서 직접 하는 것보다는 state를 따로 만들어 주는 것이 바람직합니다.
저는 이 상태를 filteredTodos라 하겠습니다.
또한 투두 아이템에는 로직이 많이 들어가는데요 토글과 수정 삭제가 있습니다.
state
- filteredTodos: currentTab의 상태에 따라 다른 투두 배열
action
- editTodo: 투두의 id와 content를 받아 수정합니다.
- toggleTodo: 투두의 완료 상태를 전환합니다.
- deleteTodo: 투두의 id를 받아 삭제합니다.
푸터
별거 없어 보이지만 많은 상태와 액션이 필요합니다.
state
- currentTab: 현재 탭
- remainTodosAmount: 아직 완료되지 않은 투두 개수
- completedTodoExists: 완료된 투두가 있는가?
action
- setCurrentTab: 현재 탭을 바꿉니다.
- deleteCompletedTodo: 완료된 투두를 한 번에 삭제합니다.
기타
또한 투두리스트로써 제대로 활용될 수 있도록 로컬 스토리지에 투두리스트 내용이 저장되고 불러와질 수 있도록 만들 예정입니다.
그리 하면 새로고침 해도 다시 투두 리스트를 볼 수 있겠죠?
3. 기능 구현하기
다음과 같이 작성해 놓고 하나씩 구현을 완성해 보죠!
import { useState } from "react";
export interface Todo {
id: string;
content: string;
completed: boolean;
}
export type TabState = "All" | "Active" | "Completed";
const useTodoList = () => {
const [todos, setTodos] = useState<Todo[]>([
{
id: "1",
content: "Todo 1",
completed: false,
},
{
id: "2",
content: "Todo 2",
completed: true,
},
{
id: "3",
content: "Todo 3",
completed: false,
},
]);
const [currentTab, setCurrentTab] = useState<TabState>("All");
const filteredTodos
const remainTodosAmount
const completedTodoExists
const addTodo = (content: string) => {
};
const editTodo = (id: string, content: string) => {
};
const deleteTodo = (id: string) => {
};
const deleteCompletedTodo = () => {
};
const toggleTodo = (id: string) => {
};
const toggleTodoAll = () => {
};
return {
state: {
todos,
currentTab,
filteredTodos,
remainTodosAmount,
completedTodoExists,
},
action: {
addTodo,
editTodo,
deleteTodo,
deleteCompletedTodo,
toggleTodo,
toggleTodoAll,
setCurrentTab,
},
};
};
export default useTodoList;
다음은 완성된 모습입니다!
const filteredTodos = todos.filter((todo) => {
if (currentTab === "All") {
return true;
} else if (currentTab === "Active") {
return !todo.completed;
} else if (currentTab === "Completed") {
return todo.completed;
}
});
const remainTodosAmount = todos.filter((todo) => !todo.completed).length;
const completedTodoExists = todos.some((todo) => todo.completed);
const addTodo = (content: string) => {
const newTodo: Todo = {
id: Date.now().toString(),
content,
completed: false,
};
setTodos([...todos, newTodo]);
};
const editTodo = (id: string, content: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) => (todo.id === id ? { ...todo, content } : todo))
);
};
const deleteTodo = (id: string) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
};
const deleteCompletedTodo = () => {
setTodos((prevTodos) => prevTodos.filter((todo) => !todo.completed));
};
const toggleTodo = (id: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const toggleTodoAll = () => {
const areAllCompleted = todos.every((todo) => todo.completed);
setTodos((prevTodos) =>
prevTodos.map((todo) => ({ ...todo, completed: !areAllCompleted }))
);
};
4. 기능 적용하기
다음과 같이 TodoList에서 state와 action을 불러온 뒤 props를 전달해 줍시다!
import Header from "./header";
import TodoItem from "./TodoItem";
import Footer from "./footer";
import useTodoList from "./useTodoList";
const TodoList = () => {
const {
state: {
completedTodoExists,
currentTab,
filteredTodos,
remainTodosAmount,
},
action: {
addTodo,
deleteCompletedTodo,
deleteTodo,
editTodo,
setCurrentTab,
toggleTodo,
toggleTodoAll,
},
} = useTodoList();
return (
<div className="w-[600px] max-h-[calc(100vh-200px)] flex flex-col bg-white rounded-lg drop-shadow-md">
<Header addTodo={addTodo} toggleTodoAll={toggleTodoAll} />
<div className="h-full overflow-y-auto">
{filteredTodos.map((todo) => (
<TodoItem
todo={todo}
toggleTodo={toggleTodo}
editTodo={editTodo}
deleteTodo={deleteTodo}
/>
))}
</div>
<Footer
currentTab={currentTab}
setCurrentTab={setCurrentTab}
completedTodoExists={completedTodoExists}
remainTodosAmount={remainTodosAmount}
deleteCompletedTodo={deleteCompletedTodo}
/>
</div>
);
};
export default TodoList;
이제 각 하위 컴포넌트들에서 props를 받고 핸들러를 구현합시다!
헤더
import { FaCheck } from "react-icons/fa";
interface HeaderProps {
addTodo: (content: string) => void;
toggleTodoAll: () => void;
}
const Header = ({ addTodo, toggleTodoAll }: HeaderProps) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.nativeEvent.isComposing) return;
const target = e.currentTarget;
const content = target.value.trim();
if (content) {
addTodo(content);
target.value = "";
}
}
};
return (
<div className="flex gap-2 items-stretch px-4 h-16 focus-within:outline focus-within:outline-2 focus-within:outline-blue-300 border-b-2 border-stone-300 shrink-0">
<button
className="w-8 flex items-center justify-center hover:text-stone-600 text-stone-400"
onClick={toggleTodoAll}
>
<FaCheck />
</button>
<input
type="text"
className="flex-1 focus:outline-none text-lg placeholder:italic"
placeholder="What needs to be done?"
onKeyDown={handleKeyDown}
/>
</div>
);
};
export default Header;
인풋을 처리해 주는 부분을 유심히 보시기 바랍니다. 따로 state로 관리해주지는 않았습니다.
다음 코드는 한글과 같이 글자의 조합으로 단어가 만들어지는 언어일 경우 꼭 처리해주어야 하는데요
조합 문자에 대해서는 키 관련 이벤트가 두 번씩 호출되기 때문입니다.
따라서 e.nativeEvent.isComposing 속성이 참일 경우에 두 번째로 호출된 필요 없는 이벤트를 무시할 수 있습니다.
if (e.nativeEvent.isComposing) return;
투두 아이템
투두 아이템에는 자체 상태가 하나 필요한데요 수정을 해야 하기 때문입니다.
editMode 상태를 통해 input이 되거나 p가 됩니다.
import { FaRegTrashAlt, FaRegCheckCircle, FaRegCircle } from "react-icons/fa";
import { type Todo } from "./useTodoList";
import { useState } from "react";
interface TodoItemProps {
todo: Todo;
toggleTodo: (id: string) => void;
editTodo: (id: string, content: string) => void;
deleteTodo: (id: string) => void;
}
const TodoItem = ({
todo,
deleteTodo,
editTodo,
toggleTodo,
}: TodoItemProps) => {
const [editMode, setEditMode] = useState(false);
const handleDoubleClick = () => {
setEditMode(!editMode);
};
const handleKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.nativeEvent.isComposing) return;
const target = e.currentTarget;
const content = target.value.trim();
if (content) {
editTodo(todo.id, content);
target.value = "";
}
setEditMode(false);
}
};
const handleClickDeleteButton = () => {
deleteTodo(todo.id);
};
const handleClickToggleButton = () => {
toggleTodo(todo.id);
};
return (
<div
className="px-4 gap-2 group flex items-center h-16 border-b-[1px] border-stone-300 shrink-0"
onDoubleClick={handleDoubleClick}
>
{todo.completed ? (
<button
className="flex w-8 justify-center items-center"
onClick={handleClickToggleButton}
>
<FaRegCheckCircle className="w-6 h-6 text-stone-400 hover:text-stone-600" />
</button>
) : (
<button
className="flex w-8 justify-center items-center"
onClick={handleClickToggleButton}
>
<FaRegCircle className="w-6 h-6 text-stone-400 hover:text-stone-600" />
</button>
)}
{editMode ? (
<input
className={`flex-1 text-lg ${
todo.completed ? "text-stone-400 line-through" : "text-stone-600"
}`}
defaultValue={todo.content}
onKeyDown={handleKeydown}
autoFocus
/>
) : (
<p
className={`flex-1 text-lg ${
todo.completed ? "text-stone-400 line-through" : "text-stone-600"
}`}
>
{todo.content}
</p>
)}
<button
className="group-hover:visible invisible w-8"
onClick={handleClickDeleteButton}
>
<FaRegTrashAlt className="text-red-500 w-5 h-5" />
</button>
</div>
);
};
export default TodoItem;
푸터
의외로 푸터도 좀 복잡하네요~
import { type TabState } from "./useTodoList";
interface FooterProps {
currentTab: TabState;
setCurrentTab: (tab: TabState) => void;
completedTodoExists: boolean;
remainTodosAmount: number;
deleteCompletedTodo: () => void;
}
const Footer = ({
currentTab,
setCurrentTab,
completedTodoExists,
remainTodosAmount,
deleteCompletedTodo,
}: FooterProps) => {
return (
<div className="flex px-4 items-center h-12 justify-center shrink-0 border-t-[1px] border-stone-300">
<p className="absolute left-4 text-sm text-stone-500">
{remainTodosAmount} item left
</p>
<div className="flex gap-4">
{(["All", "Active", "Completed"] as TabState[]).map((tab) => (
<button
key={tab}
onClick={setCurrentTab.bind(null, tab)}
className={`p-1 text-lg hover:text-stone-600 ${
currentTab === tab
? "outline outline-1 outline-stone-300 text-stone-600"
: "text-stone-400"
}`}
>
{tab}
</button>
))}
</div>
{completedTodoExists && (
<button
className="absolute right-4 text-stone-400 hover:text-stone-600"
onClick={deleteCompletedTodo}
>
Clear completed
</button>
)}
</div>
);
};
export default Footer;
여기서 주의 깊게 보셔야 할 부분은 button에 onclick 핸들러를 넘겨주는 모습입니다.
다음 두 가지 방식이 있을 수 있는데요 bind는 this를 바인드 한 뒤 인자를 넘기고 새로운 함수를 만들어주기 때문에 화살표함수를 안 쓸 수 있습니다. 취향 차이이니 둘 다 알아두시면 좋습니다.
onClick={() => setCurrentTab(tab)}
setCurrentTab.bind(null, tab)
로컬에 저장하기
자! 여기까지 왔다면 이미 투두리스트로써 동작은 전부 완벽하게 할 겁니다. 이제 로컬스토리지에 저장하고 불러오는 기능을 만들어야 하는데요. 간단하게 useTodoList 내부에 다음과 같이 작성해 줍시다.
useEffect(() => {
const savedData = localStorage.getItem("todo-list");
if (savedData) {
const { todos, currentTab } = JSON.parse(savedData);
setTodos(todos);
setCurrentTab(currentTab);
}
}, []);
useEffect(() => {
localStorage.setItem("todo-list", JSON.stringify({ todos, currentTab }));
}, [todos, currentTab]);
그럴싸 해보이는 코드입니다.
그러나 투두리스트가 완전히 독립적이기 위해서는 한 가지 더 해줄 필요가 있어 보이는데요.
만약 서비스 여기저기에서 투두리스트를 사용할 경우 로컬스토리지 키가 중복될 수 있습니다. 따라서 그 키는 더 상위에서 투두리스트를 사용하는 곳에서 관리해 줄 필요가 있지요. 현재 프로젝트에서는 App입니다.
다음과 같이 TodoList가 id를 Props로 받게 만듭니다.
interface TodoListProps {
id: string;
}
const TodoList = ({ id }: TodoListProps) => {
//...
또한 useTodoList도 이 id를 이용하여 로컬 저장 로직을 변경해봅시다.
const useTodoList = (id: string) => {
const [todos, setTodos] = useState<Todo[]>([]);
const [currentTab, setCurrentTab] = useState<TabState>("All");
useEffect(() => {
const savedData = localStorage.getItem(`todo-list-${id}`);
if (savedData) {
const { todos, currentTab } = JSON.parse(savedData);
setTodos(todos);
setCurrentTab(currentTab);
}
}, [id]);
useEffect(() => {
localStorage.setItem(
`todo-list-${id}`,
JSON.stringify({ todos, currentTab })
);
}, [todos, currentTab, id]);
// ...
그다음 App에서 다음과 같이 id를 주입해 줍시다.
import TodoList from "@components/TodoList";
const TODO_LISTS = ["1", "2"];
function App() {
return (
<div className="w-screen h-screen flex-col gap-4 flex items-center bg-stone-100">
<h1 className="font-serif font-light text-6xl mt-8 text-stone-400">
todos
</h1>
{TODO_LISTS.map((id) => (
<TodoList id={id} />
))}
</div>
);
}
export default App;
잘 되는지 두 개를 만들어 봤는데요 각각 독립적으로 사용할 수 있고 새로고침을 해도 각각 불러와지지요?
5. 배포하기
자 배포하는 방법은 어렵지 않습니다. github-pages라는 기능을 활용하면 되는데요.
github에 정적 파일을 올려놓으면 이를 호스팅 해줍니다!
다양하게 적용하거나 활용하는 방법이 있으나 이번 시간에는 간단하게 배포하겠습니다.
먼저 현재 프로젝트를 깃허브 레포지토리에 올려주기 위해 레포지토리를 미리 만들어주세요! (README.md 만들기로 만들지 마세요)
다음 vite.config.ts를 수정해 주세요!
다음과 같이 base: "/레포지토리 이름/"을 추가해 주세요!
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
base: "/vite-todo-list/",
plugins: [react(), tsconfigPaths()],
server: {
host: "localhost",
port: 3000,
},
});
그리고 다음 명령어를 쳐주세요!
@@깃 초기화하기@@
git init
git add .
git commit -m "todoList 만들기"
@@저장소에 올리기@@
git remote add origin https://github.com/~~
git branch -M main
git push -u origin main
@@배포 작업용 브렌치로 옮기기!@@
git checkout -b release
pnpm build
@@!!.gitignore에 dist를 지워서 깃에 추적시켜줘야 함!!@@
git add .
git commit -m "배포"
@@dist 디렉토리만 gh-pages로 올려서 배포!@@
git subtree push --prefix dist origin gh-pages
그러면 다음과 같이 배포된 것을 볼 수 있습니다.
https://sjsjsj1246.github.io/vite-todo-list/
다시 main 브렌치에서 수정도 하다가 배포를 다시 하시고 싶으면
release 브렌치로 와서 main을 머지 후 다시 빌드, 커밋, git subtree push 하시면 됩니다!
여기까지 오느라 고생하셨습니다!
이 정도면 기본적인 투두리스트 기능은 완료한 셈입니다. 다음 시간부터는 앱 기능보다는 테스트를 하고 프로젝트를 어떻게 관리하는지 설명드리도록 하겠습니다.
전체 코드는 아래 레포지토리를 참고해주세요!
https://github.com/sjsjsj1246/vite-todo-list/tree/ver2-FeatureDevelop
'FrontEnd' 카테고리의 다른 글
React Testing Library & Vitest로 테스트 하기, CI 적용하기 (0) | 2024.08.18 |
---|---|
MSW로 API Mocking하기, React Query 사용하기 (1) | 2024.03.17 |
Vite로 시작하기, TailwindCss로 TodoList UI 구성하기 (0) | 2024.01.21 |
현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) (2) | 2024.01.20 |