이번 포스팅은 지난 회원가입 폼에 커스텀 훅을 넣어본 것과 같이 투두리스트에도 커스텀 훅을 적용해보기로 했다. 커스텀 훅에 대한 이야기와 왜 이러한 작업과 포스팅을 하는지는 지난 커스텀 훅 포스팅을 읽으면 될 것 같다.
그럼 어떻게 작성하고 리팩토링을 진행했는지 적어보겠습니다. 이번 투두리스트도 마찬가지로 css에 대한 연습이 아니였기 때문에 매우매우 안이쁜점은 감안해주세여.
1. 분리없는 기본적인 코드
import { useEffect, useState } from "react";
const initialTodos = [
{
id: 1,
text: "회원가입 폼 커스텀 훅",
completed: true,
},
{
id: 2,
text: "투두리스트 커스텀 훅",
completed: false,
},
{
id: 3,
text: "일단 할 것3",
completed: false,
},
];
function TodoPage() {
const [todos, setTodos] = useState(initialTodos);
const [todo, setTodo] = useState("");
const [editToggle, setEditToggle] = useState(false);
const [selectTodo, setSelectTodo] = useState(0);
const [editValue, setEditValue] = useState("");
useEffect(() => {
if (selectTodo) {
setEditValue(todos[selectTodo - 1].text);
}
}, [selectTodo]);
const editChangeHandler = (e) => {
setEditValue(e.target.value);
};
const onEditToggle = () => {
if (selectTodo) {
setSelectTodo(null);
setEditValue(selectTodo.text);
}
setEditToggle((prev) => !prev);
};
const changeHandler = (e) => {
setTodo(e.target.value);
};
const editChange = (id) => {
onEditToggle();
setTodos((todos) =>
todos.map((todo) =>
todo.id === id ? { ...todo, text: editValue } : todo
)
);
};
const editSubmit = (e) => {
e.preventDefault();
editChange(selectTodo);
setEditValue("");
setEditToggle(false);
};
const addTodo = () => {
const newTodo = {
id: todos.length + 1,
text: todo,
completed: false,
};
setTodos([...todos, newTodo]);
};
const removeHandler = (id) => {
const filteredTodos = todos.filter((todo) => todo.id !== id);
setTodos(filteredTodos);
};
const completeHandler = (id) => {
const toggledTodos = todos.map((todo) => {
return todo.id === id ? { ...todo, completed: !todo.completed } : todo;
});
setTodos(toggledTodos);
};
const submitHandler = (e) => {
e.preventDefault();
if (todo === "") return;
addTodo();
setTodo("");
};
const onselectTodo = (id) => {
setEditToggle(true);
setSelectTodo(id);
};
return (
<div className="container">
<form action="#" onSubmit={submitHandler}>
<input
type="text"
name="todo"
id="todo"
placeholder="할 일을 적으세요."
value={todo}
onChange={changeHandler}
/>
<button type="submit">추가</button>
</form>
<div className="todoList">
<ul>
{todos.map((item) => (
<li key={item.id}>
<span>{item.text}</span>
<button onClick={() => completeHandler(item.id)}>
{item.completed ? "미완료" : "완료"}
</button>
<button onClick={() => onselectTodo(item.id)}>수정</button>
<button onClick={() => removeHandler(item.id)}>삭제</button>
</li>
))}
</ul>
</div>
{editToggle && (
<>
<form action="#" onSubmit={editSubmit}>
<input
type="text"
onChange={editChangeHandler}
value={editValue}
/>
<button type="submit">수정하기</button>
<button>취소하기</button>
</form>
</>
)}
</div>
하나의 TodoPage.jsx 파일에 비즈니스 로직과 ui 로직을 몽땅 넣어서 작성한 코드이다. 회원가입 폼과 마찬가지로 엄청나게 코드가 길어서 가독성도 크게 떨어지고, 코드 수정과정도 엄청 험난하게 보인다. 여기서 규모가 더 커진다면 진짜 아찔하다. 그럼 가독성과 수정과정을 좀더 편하게 바꿔보자.
2. 상수데이터 분리 시키기
// const.js
export const INITIAL_TODOS = [
{
id: "1",
text: "회원가입 폼 커스텀 훅",
completed: true,
},
{
id: "2",
text: "투두리스트 커스텀 훅",
completed: false,
},
{
id: "3",
text: "일단 할 것3",
completed: false,
},
];
초기 투두로 변하지 않는 값인 상수 데이터들을 분리하여 UPPER_SNAKE_CASE 네이밍 컨벤션을 따라 작성해주었다. 비록 지금은 상수 데이터가 1개뿐이고, 간단한 값들이기 때문에 크게 가독성이 좋다고 보이지는 않지만, 상수 데이터가 많아지거나, 값들이 복잡하고 많아지면 꼭 파일을 따로 분리하여 작성할 필요가 있다.
3. 컴포넌트 분리
많이 해보았던 작업이였기 때문에, 이 부분도 크게 어려운점은 없었다. 투두 추가를 위한 작성컴포넌트, 투두리스트가 보여질 리스트컴포넌트, 리스트 중에 투두 하나하나를 나타낼 아이템컴포넌트, editMode에 따라 보여질 수정컴포넌트 이렇게 총 4개의 컴포넌트로 가독성과 유지보수시 편리하게 하기위해 나누었다.
// TodoPage.jsx
import uuid from "react-uuid";
import { useEffect, useState } from "react";
import WriteTodo from "./components/WriteTodo";
import TodoList from "./components/TodoList";
import EditTodo from "./components/EditTodo";
import { INITIAL_TODOS } from "./const/const";
function TodoPage() {
const [todos, setTodos] = useState(INITIAL_TODOS);
const [todo, setTodo] = useState("");
const [editToggle, setEditToggle] = useState(false);
const [selectTodo, setSelectTodo] = useState("");
const [editValue, setEditValue] = useState("");
useEffect(() => {
if (selectTodo) {
const index = todos.findIndex((todo) => todo.id === selectTodo);
setEditValue(todos[index].text);
}
}, [selectTodo]);
const editChangeHandler = (e) => {
setEditValue(e.target.value);
};
const onEditToggle = () => {
if (selectTodo) {
setSelectTodo(null);
setEditValue(selectTodo.text);
}
setEditToggle((prev) => !prev);
};
const changeHandler = (e) => {
setTodo(e.target.value);
};
const editChange = (id) => {
onEditToggle();
setTodos((todos) =>
todos.map((todo) =>
todo.id === id ? { ...todo, text: editValue } : todo
)
);
};
const editSubmit = (e) => {
e.preventDefault();
editChange(selectTodo);
setEditValue("");
setEditToggle(false);
};
const addTodo = () => {
const newTodo = {
id: uuid(),
text: todo,
completed: false,
};
setTodos([...todos, newTodo]);
};
const removeHandler = (id) => {
const filteredTodos = todos.filter((todo) => todo.id !== id);
setTodos(filteredTodos);
};
const completeHandler = (id) => {
const toggledTodos = todos.map((todo) => {
return todo.id === id ? { ...todo, completed: !todo.completed } : todo;
});
setTodos(toggledTodos);
};
const submitHandler = (e) => {
e.preventDefault();
if (todo === "") return;
addTodo();
setTodo("");
};
const onselectTodo = (id) => {
setEditToggle(true);
setSelectTodo(id);
};
return (
<div className="container">
<WriteTodo
todo={todo}
submitHandler={submitHandler}
changeHandler={changeHandler}
/>
<TodoList
todos={todos}
completeHandler={completeHandler}
onselectTodo={onselectTodo}
removeHandler={removeHandler}
/>
{editToggle && (
<EditTodo
editValue={editValue}
editChangeHandler={editChangeHandler}
editSubmit={editSubmit}
/>
)}
</div>
);
}
export default TodoPage;
// WriteTodo.jsx
function WriteTodo({ submitHandler, todo, changeHandler }) {
return (
<>
<form action="#" onSubmit={submitHandler}>
<input
type="text"
name="todo"
id="todo"
placeholder="할 일을 적으세요."
value={todo}
onChange={changeHandler}
/>
<button type="submit">추가</button>
</form>
</>
);
}
export default WriteTodo;
// TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, completeHandler, onselectTodo, removeHandler }) {
return (
<div className="todoList">
<ul>
{todos.map((item) => (
<li key={item.id}>
<TodoItem
item={item}
completeHandler={completeHandler}
onselectTodo={onselectTodo}
removeHandler={removeHandler}
/>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
// TodoItem.jsx
function TodoItem({ item, completeHandler, onselectTodo, removeHandler }) {
return (
<>
<span>{item.text}</span>
<button onClick={() => completeHandler(item.id)}>
{item.completed ? "미완료" : "완료"}
</button>
<button onClick={() => onselectTodo(item.id)}>수정</button>
<button onClick={() => removeHandler(item.id)}>삭제</button>
</>
);
}
export default TodoItem;
// EditTodo.jsx
import { useEffect, useState } from "react";
function EditTodo({
editValue,
editChangeHandler,
editSubmit
}) {
return (
<>
<form action="#" onSubmit={editSubmit}>
<input
type="text"
onChange={editChangeHandler}
value={editValue}
/>
<button type="submit">수정하기</button>
<button>취소하기</button>
</form>
</>
);
}
export default EditTodo;
4. useInput, useArray 커스텀 훅
먼저 해당 투두리스트에서의 input은 할일 추가, 할일 수정에서 재사용된다. 또한, 규모가 커질 때 input은 재사용될 확률도 충분히 있기 때문에 커스텀 훅으로 만들어보자. input이 사용되는 로직에 공통적으로 들어가는 것들을 생각해보자. input의 value를 저장할 상태가 있고, 사용자의 이벤트에 따라 상태변경함수를 통해 상태값을 바꾸어 주어야하고, submit시 input을 정리해주는 로직이 공통적으로 사용된다. 따라서 이러한 공통적인 로직들을 리턴해주는 커스텀훅을 따로 빼주어 재사용해보자.
// useInput.js
import { useState } from "react";
const useInput = (initialValue) => {
const [inputState, setInputState] = useState(initialValue);
const changeHandler = (e) => {
setInputState(e.target.value);
};
const reset = () => {
setInputState("");
};
return [inputState, changeHandler, reset, setInputState];
};
export default useInput;
위처럼 공통적으로 사용되는 로직들을 반환해주는 커스텀 훅을 만들어서 사용하고자 하는 컴포넌트에서 가져다가 재사용해줄 수 있다.
input이 사용되는 WriteTodo 컴포넌트를 통해 수정된 코드를 보자.
// 수정 전 input 사용 코드
const [todo, setTodo] = useState("");
const changeHandler = (e) => {
setTodo(e.target.value);
};
const submitHandler = (e) => {
e.preventDefault();
if (todo === "") return;
addTodo();
setTodo("");
};
// 수정 후 WriteTodo 일부분
const [inputState, changeHandler, resetInput] = useInput("");
const submitHandler = (e) => {
e.preventDefault();
if (inputState === "") return;
addTodo();
resetInput(); // 변경점
};
return (
<>
<form action="#" onSubmit={submitHandler}>
<input
type="text"
name="todo"
id="todo"
placeholder="할 일을 적으세요."
value={inputState} // 변경점
onChange={changeHandler} // 변경점
/>
<button type="submit">추가</button>
</form>
</>
);
다음은 useArray 커스텀훅을 살펴보자.
useArray는 투두리스트가 아니더라도 배열로 되어있는 데이터를 사용하여 코딩을 할 일이 많기 때문에 배열을 처리하기 위한 커스텀 훅을 만들어보는 연습도 상당히 좋을 것 같다고 생각했다. 다양한 작업들을 사용할 수 있지만, 이번은 배열에 추가, 제거, 수정, 토글을 할 수 있는 로직을 커스텀 훅으로 분리해보기로 했다.
// useArray.js
import { useState } from "react";
import uuid from "react-uuid";
const useArray = (initialList) => {
const [list, setList] = useState(initialList);
return {
list,
setList,
addItem: (newItemText) => {
const newItem = {
id: uuid(),
text: newItemText,
completed: false,
};
setList([...list, newItem]);
},
removeItem: (itemId) => {
const filteredArray = list.filter((item) => item.id !== itemId);
setList(filteredArray);
},
completeItem: (itemId) => {
const toggledArray = list.map((item) => {
return item.id === itemId
? { ...item, completed: !item.completed }
: item;
});
setList(toggledArray);
},
editItem: (itemId, editState) => {
setList((list) =>
list.map((item) =>
item.id === itemId ? { ...item, text: editState } : item
)
);
},
};
};
export default useArray;
배열인 list 여러가지 함수들을 반환하는 useArray를 작성하고, TodoPage에서 커스텀 훅이 반환한 객체를 받아서 lists에 할당하고, 자식 컴포넌트들에게 뿌려주는 식으로 사용해보았다.
// TodoPage.jsx 일부분
const lists = useArray(INITIAL_TODOS);
return (
<div className="container">
<WriteTodo lists={lists} />
<TodoList lists={lists} onselectTodo={onselectTodo} />
{editToggle && (
<EditTodo
lists={lists}
selectTodo={selectTodo}
setEditToggle={setEditToggle}
/>
)}
</div>
);
WriteTodo 컴포넌트에서의 예를 들어보면
// WriteTodo.jsx
import useInput from "../hooks/useInput";
function WriteTodo({ lists }) {
const [inputState, changeHandler, resetInput] = useInput("");
const submitHandler = (e) => {
e.preventDefault();
if (inputState === "") return;
lists.addItem(inputState); // 변경점
resetInput();
};
return (
<>
<form action="#" onSubmit={submitHandler}>
<input
type="text"
name="todo"
id="todo"
placeholder="할 일을 적으세요."
value={inputState}
onChange={changeHandler}
/>
<button type="submit">추가</button>
</form>
</>
);
}
export default WriteTodo;
부모 컴포넌트인 TodoPage에서 받은 lists라는 객체를 통해 lists중에 addItem함수를 사용하기 위해 lists.addItem를 통해 사용할 수 있었다. 이렇게 useArray 커스텀훅에서 재사용될 만한 로직을 가져다가 쓸 수도 있고, 비즈니스로직을 분리할 수 있다는 점에서 가독성도 눈에 띄게 좋아지고, 유지보수도 아주 편리해졌다. 앞으로 프로젝트를 진행하게 될 때 커스텀 훅을 활용한 비즈니스로직 분리를 꼭 리팩토링을 통해 진행해야겠다고 느꼈고, 비즈니스로직 분리 자체에 큰 의미가 있고, 많은 장점이 있다는 것을 직접 코딩을 통해 알 수 있는 계기가 되었다.
5. 완성코드
미래의 내가 한번쯤을 다시 볼 수 있기 때문에 전체코드 올려보겠숩니다. 커스텀 훅 완성코드들은 4번의 코드들이 최종코드이기 때문에 안올림.
// TodoPage.jsx
import { useState } from "react";
import WriteTodo from "./components/WriteTodo";
import TodoList from "./components/TodoList";
import EditTodo from "./components/EditTodo";
import useArray from "./hooks/useArray";
import { INITIAL_TODOS } from "./const/const";
function TodoPage() {
const [editToggle, setEditToggle] = useState(false);
const [selectTodo, setSelectTodo] = useState("");
const lists = useArray(INITIAL_TODOS);
const onselectTodo = (id) => {
setEditToggle(true);
setSelectTodo(id);
};
return (
<div className="container">
<WriteTodo lists={lists} />
<TodoList lists={lists} onselectTodo={onselectTodo} />
{editToggle && (
<EditTodo
lists={lists}
selectTodo={selectTodo}
setEditToggle={setEditToggle}
/>
)}
</div>
);
}
export default TodoPage;
// WriteTodo.jsx
import useInput from "../hooks/useInput";
function WriteTodo({ lists }) {
const [inputState, changeHandler, resetInput] = useInput("");
const submitHandler = (e) => {
e.preventDefault();
if (inputState === "") return;
lists.addItem(inputState);
resetInput();
};
return (
<>
<form action="#" onSubmit={submitHandler}>
<input
type="text"
name="todo"
id="todo"
placeholder="할 일을 적으세요."
value={inputState}
onChange={changeHandler}
/>
<button type="submit">추가</button>
</form>
</>
);
}
export default WriteTodo;
// TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ lists, onselectTodo }) {
return (
<div className="todoList">
<ul>
{lists.list.map((item) => (
<li key={item.id}>
<TodoItem
item={item}
completeHandler={lists.completeItem}
onselectTodo={onselectTodo}
removeHandler={lists.removeItem}
/>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
// TodoItem.jsx
function TodoItem({ item, completeHandler, onselectTodo, removeHandler }) {
return (
<>
<span>{item.text}</span>
<button onClick={() => completeHandler(item.id)}>
{item.completed ? "미완료" : "완료"}
</button>
<button onClick={() => onselectTodo(item.id)}>수정</button>
<button onClick={() => removeHandler(item.id)}>삭제</button>
</>
);
}
export default TodoItem;
// EditTodo.jsx
import { useEffect } from "react";
import useInput from "../hooks/useInput";
function EditTodo({ lists, selectTodo, setEditToggle }) {
const [inputState, changeHandler, resetInput, setInputState] = useInput("");
const index = lists.list.findIndex((todo) => todo.id === selectTodo);
useEffect(() => {
if (selectTodo) {
setInputState(lists.list[index].text);
}
}, [selectTodo]);
const editSubmit = (e) => {
e.preventDefault();
lists.editItem(selectTodo, inputState);
resetInput();
setEditToggle(false);
};
return (
<>
<form action="#" onSubmit={editSubmit}>
<input type="text" onChange={changeHandler} value={inputState} />
<button type="submit">수정하기</button>
<button>취소하기</button>
</form>
</>
);
}
export default EditTodo;