이 글에서는 React와 TypeScript로 TodoList의 추가, 삭제 기능을 구현하는 프로젝트 내용을 담고 있습니다.
root
├── node_modules
├── public
├── src
│ ├── components
│ │ ├── NewTodo.module.css
│ │ ├── NewTodo.tsx
│ │ ├── TodoItem.module.css
│ │ ├── TodoItem.tsx
│ │ ├── Todos.module.css
│ │ ├── Todos.tsx
│ ├── models
│ │ ├── todo.ts
│ ├── App.css
│ ├── App.tsx
│ ├── index.css
│ ├── index.tsx
│ └── react-app-env.d.ts
├── .gitignore
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json
ts -> js로 컴파일하는 경우에 이 파일로 컴파일과 관련된 사항을 구성합니다.
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
target: 작성한 ts코드를 어떤 버전의 js코드로 변환할건지 결정합니다.
lib: 기본 타입스크립트 라이브러리
allowJS: js파일을 포함할 수 있는가의 여부를 결정합니다.
strict: true로 설정하면 이 프로젝트에 가장 엄격한 설정이 적용됩니다.
jsx: jsx코드를 지원할 것인가에 대한 여부를 결정합니다.
Todo의 형태를 정의합니다.
// models/todo.ts
class Todo {
id: string;
text: string;
constructor(todoText: string) {
this.text = todoText;
this.id = new Date().toISOString();
}
}
export default Todo;
어떠한 형태를 정의할 때는 interface, type, class 등을 사용할 수 있는데 여기서는 class의 형태로 Todo를 정의하였습니다.
Todo
의 id
와 text
에 값이 할당되는 부분이 없다면 에러가 발생합니다.
이는 "인스턴스화가 되지 않기 때문"입니다.
따라서 constructor
로 값을 할당해줍니다.
이 때 id
는 Todo
가 만들어질 때마다 현재 날짜와 시간으로 자동 생성됩니다.
가장 메인이 되는 파일로 컴포넌트들이 렌더링됩니다.
// App.tsx
import { useState } from 'react';
import NewTodo from './components/NewTodo';
import Todos from './components/Todos';
import Todo from './models/todo';
function App() {
const [todos, setTodos] = useState<Todo[]>([]);/
const addTodoHandler = (todoText: string) => {
const newTodo = new Todo(todoText);
setTodos((prevTodos) => {
return prevTodos.concat(newTodo);
});
}
const removeTodoHandler = (todoId: string) => {
setTodos((prevTodos) => {
return prevTodos.filter((todo) => todo.id !== todoId);
})
};
return (
<div>
<NewTodo onAddTodo={addTodoHandler} />
<Todos items={todos} onRemoveTodo={removeTodoHandler} />
</div>
);
}
export default App;
const [todos, setTodos] = useState<Todo[]>([]);
react가 제공하는 useState
를 사용하여 Todo
배열의 상태를 관리합니다.
이 때 useState([]);
로 빈 배열만 넘겨줄 경우에는 에러가 발생합니다.
빈 배열을 넘김으로써 Todo
가 어떤 타입의 값을 가지는지가 모호해지기 때문입니다.
따라서 제네릭함수인 useState
는 제네릭으로 Todo[]
라는 타입을 가진다고 알려주어야 합니다.
const addTodoHandler = (todoText: string) => {
const newTodo = new Todo(todoText);
setTodos((prevTodos) => {
return prevTodos.concat(newTodo);
});
}
파라미터로 받은 todoText
를 가진 newTodo
를 생성합니다.
그 후 setState
로 이전의 todo list인 prevTodos
에 newTodo
를 더하여 할 일을 추가합니다.
const removeTodoHandler = (todoId: string) => {
setTodos((prevTodos) => {
return prevTodos.filter((todo) => todo.id !== todoId);
})
};
동일하게 setState
로 Todo
를 삭제하는 코드입니다.
이전의 Todo[]
들이 담긴 prevTodos
에서 삭제할 todo의 id값인 todoId
와 Todo[]
에서의 id
가 다른 것들로 새 배열을 만들어줍니다.
이것으로 Todo
삭제를 구현할 수 있습니다.
return (
<div>
<NewTodo onAddTodo={addTodoHandler} />
<Todos items={todos} onRemoveTodo={removeTodoHandler} />
</div>
);
}
NewTodo
: 새 Todo
를 생성하는 form
입니다.Todos
: 생성된 Todo[]
를 보여주는 list
입니다.NewTodo
에서 생성버튼을 누르면 onAddTodo
가 실행되며 전달된 함수인 addTodoHandler
를 통해 Todo
가 생성됩니다.
Todos
에서 각각의 Todo
는 items
라는 이름으로 전달되며 todos
를 클릭할 시 onRemoveTodo
가 실행되며 전달된 함수인 removeTodoHandler
를 통해 클릭된 todos
가 삭제됩니다.
TodoItem 컴포넌트가 렌더링되어 TodoList를 볼 수 있습니다.
// components/Todos.tsx
import React from 'react';
import Todo from '../models/todo';
import TodoItem from './TodoItem';
import classes from './Todos.module.css';
const Todos: React.FC<{ items: Todo[]; onRemoveTodo: (id: string) => void }> = (props) => {
return (
<ul className={classes.todos}>
{props.items.map((item) => (
<TodoItem
key={item.id}
text={item.text}
onRemoveTodo={props.onRemoveTodo.bind(null, item.id)}
/>
))}
</ul>
);
}
export default Todos;
여기서 중요한 것은 React.FC입니다.
React.FC : 리액트 패키지에 정의된 타입으로 이를 통해 이 함수가 함수형 컴포넌트로 동작한다는 사실을 명확히 할 수 있습니다.
또한 React.FC는 제네릭 타입입니다.
만약 React.FC를 타입으로 가지는 함수가 props와 같은 프로퍼티를 가진다면 해당 프로퍼티의 타입을 제네릭으로 정의할 수 있습니다.
const Todos: React.FC<{ items: Todo[]; onRemoveTodo: (id: string) => void }> = (props) => {
Todo[]
인 items
와, 파라미터 id
의 타입이 string
이고 반환 타입이 void
인 함수 onRemoveTodo
가 있습니다. TodoItem 컴포넌트를 불러와 ul>li list형식으로 TodoList 나타냅니다.
return (
<ul className={classes.todos}>
{props.items.map((item) => (
<TodoItem
key={item.id}
text={item.text}
onRemoveTodo={props.onRemoveTodo.bind(null, item.id)}
/>
))}
</ul>
);
className
: css파일의 className을 의미합니다.Todos
의 props
에는 Todo[]
가 담긴 items
가 존재합니다.
이 items
를 map
을 이용해 각각의 값 하나씩을 item
이라는 이름으로 불러와 Todo
class의 속성인 id
와 text
를 각각 불러옵니다.
불러온 id
와 text
는 TodoItem
컴포넌트에 전달하여 Todo
를 리스트로 보여줄 수 있게 됩니다.
onRemoveTodo
에는 TodoItem.tsx
에서 TodoItem
이 클릭되었을 때 동작하는 onRemoveTodo
함수를 건네주는데 이 때 bind
함수가 사용됩니다.
bind() : js에서 제공하는 메서드로 실행할 함수를 미리 설정할 수 있습니다.
보통 삭제하기 위해서는 id
같은 고유한 값이 전달되어야 하는데 그냥 onRemoveTodo={props.onRemoveTodo}
로만 전달해버리면 id
를 받을 수 없고 그로 인해 삭제할 값이 무엇인지를 판단할 수 없습니다.
따라서 bind
를 통해 item.id
를 전달하는 것입니다.
사용자에게 입력창을 제공하고 사용자가 입력한 Todo의 내용을 가져올 수 있습니다.
// components/NewTodo.tsx
import { useRef } from 'react';// 레퍼런스 생성 가능
import classes from './NewTodo.module.css';
const NewTodo: React.FC<{onAddTodo: (text: string) => void }> = (props) => {
const todoTextInput = useRef<HTMLInputElement>(null);
const submitHandler = (event: React.FormEvent) => {/
event.preventDefault();
const enteredText = todoTextInput.current!.value;
if ( enteredText.trim().length === 0 ) {
return;
}
props.onAddTodo(enteredText);
}
return (
<form onSubmit={submitHandler} className={classes.form}>
<label htmlFor="text">Todo text</label>
<input type="text" id="text" ref={todoTextInput}/>
<button>Add Todo</button>
</form>
);
}
export default NewTodo;
const todoTextInput = useRef<HTMLInputElement>(null);
"react" 에서 레퍼런스를 생성하기 위해 제공하는
useRef
로html
의input
요소를 연결한 코드입니다.
useRef
에 제네릭으로 타입이 표현되어 있는 이유는 useRef
만으로 이 레퍼런스가 input
에 연결될지 button
에 연결될지를 모르기 때문입니다.
useRef
도 마찬가지로 제네릭 타입으로 정의되어 있습니다.
이 레퍼런스에 다른 요소가 할당되어 있을 수 있기 때문입니다.
현재로서는 null
이 들어가 있으나 값을 입력한 뒤 제출 버튼을 눌렀을 때는 todoTextInput
에 해당 input
이 제대로 들어가있을 것입니다.
사용자가 form의 submit button을 클릭했을 때 호출됩니다.
// 폼 제출 함수
const submitHandler = (event: React.FormEvent) => {
event.preventDefault();
const enteredText = todoTextInput.current!.value;/
if ( enteredText.trim().length === 0 ) {
return;
}
props.onAddTodo(enteredText);
}
const submitHandler = (event: React.FormEvent) => {
event.preventDefault();
onSubmit
이벤트를 수신할 때, 즉 form
을 제출했을 때 자동적으로 받게 됩니다.
preventDefault()
로 자동 제출을 막을 수 있습니다.
const enteredText = todoTextInput.current!.value;
if ( enteredText.trim().length === 0 ) {
return;
}
props.onAddTodo(enteredText);
todoTextInput.current!.value
에는 input
의 실제 텍스트 값이 들어가 있습니다.
이 때 기본적으로 !
가 아닌 ?
가 처음으로 생기는데 이 둘의 차이를 알아봅시다.
? : 일단 값에 접근은 해보고 접근이 가능하면 입력된 값을 가져와서 enteredText에 저장해
enteredText
의 타입에는 undefined
가 추가됩니다. ! : 이 값이 null이 될 수 있다는 것은 알지만 이 시점에서는 절대 null이 아니니까 입력된 값을 가져와서 enteredText에 저장해
enteredText
에 공백을 제외한 입력값이 있을 경우에 props
로 받은 onAddTodo
함수에 입력된 텍스트를 전달합니다.
이렇게 새 Todo
를 저장할 수 있습니다!
각각의 할 일들을 리스트요소로 담아 보여줍니다.
// components/TodoItem.tsx
import classes from './TodoItem.module.css'
const TodoItem: React.FC<{text: string, onRemoveTodo: () => void }> = (props) => {
return (
<li className={classes.item} onClick={props.onRemoveTodo}>{props.text}</li>
);
}
export default TodoItem;
props
에서 받아온 text
를 보여주고 해당 list
를 클릭했을 때 할 일을 삭제하는 onRemoveTodo
를 실행하여 할 일을 삭제합니다.
css를 추가하여 조금 더 다듬어진 form을 구현해봅시다.
/* Todos.module.css */
.todos {
list-style: none;
margin: 2rem auto;
padding: 0;
width: 40rem;
}
/* NewTodo.module.css */
.form {
width: 40rem;
margin: 2rem auto;
}
.form label {
display: block;
font-weight: bold;
margin-bottom: 0.5rem;
}
.form input {
display: block;
width: 100%;
font: inherit;
font-size: 1.5rem;
padding: 0.5rem;
border-radius: 4px;
background-color: #f7f5ef;
border: none;
border-bottom: 2px solid #494844;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
margin-bottom: 0.5rem;
}
.form button {
font: inherit;
background-color: #ebb002;
border: 1px solid #ebb002;
color: #201d0f;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
}
.form button:hover,
.form button:active {
background-color: #ebc002;
border-color: #ebc002;
}
/* TodoItem.module.css */
.item {
margin: 1rem 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
background-color: #f7f5ef;
}
전
후
이렇게 "Todo text" input에 할 일을 적고 "Add Todo" 버튼을 클릭하면 할 일이 아래에 추가되며, 각각의 할 일을 클릭하면 삭제되는 기능을 구현하였습니다!
몇 차례에 걸쳐 같은 프로퍼티를 계속 넘겨주는 것이 코드가 복잡해보였습니다.
그렇게 되니 자연스레 같은 타입을 여러 번 반복해서 작성하게 되니 쓸데없는 코드가 늘어나게 되었습니다.
나중에는 Context API를 사용하여 코드를 간결하게 만들어보고 싶습니다.