TypeScript를 이용해 React에서 Todo를 구현해보자.
강의 내용에 따라 TypeScript가 포함된 CRA를 설치해보고, 컴포넌트도 만들어보고 이제 props에 타입을 설정해보는 것도 배웠다.
이번 시간에는 이전에 배운 내용들을 기반으로 강의 내용에 따라 TodoList를 계속해서 만들어보도록 하겠다.
목차
2️⃣ 이벤트 객체의 타입 정의
4️⃣ 함수 타입을 정의하기
1️⃣ li 요소를 하위 컴포넌트로 분리하기
이전에 작성한 코드를 보면 아래와 같이 li
요소를 통해 Todo
의 내용들을 출력하고 있다.
import React from "react"; import Todo from "../models/todo"; const Todos: React.FC<{ items: ***Todo[]*** }> = (props) => { return ( <ul> {props.items.map((item) => ( <li key={item.id}>{item.text}</li> ))} </ul> ); }; export default Todos;
이제 이 li
요소를 하위 컴포넌트로 만들어 받아온 Todo의 내용을 출력해보고자 한다.
출력해볼 수 있는 방법은 Todo
의 속성들을 배치해서 넘기는 방법과 Todo
자체를 넘겨서 컴포넌트 내부에서 속성들을 배치하는 방법이 있다.
우선은 Todo
의 속성들을 배치해서 넘기는 방법이다.
이 내용은 위와 크게 다르지 않다. 먼저 li
를 담고 있는 하위 컴포넌트인 TodoItem
부터 살펴보자.
import React from "react"; import Todo from "./todo"; const TodoItem: React.FC<{ **text: string** }> = (props) => { return <li>{**props.text**}</li>; }; export default TodoItem;
위와 같이 Todo
의 속성들을 받아온다면, 받아올 속성들의 타입을 지정해주도록 한다.
이렇게 만든 TodoItem
컴포넌트를 아래의 Todos
컴포넌트에 배치해 보내줄 prop
들을 추가해주자.
import React from "react"; import Todo from "../models/todo"; import TodoItem from "../models/todoitem"; const Todos: React.FC<{ items: Todo[] }> = (props) => { return ( <ul> {props.items.map((item) => ( <TodoItem key={item.id} text={item.text} /> ))} </ul> ); }; export default Todos;
이제 다른 방법으로 Todo
자체를 넘겨서 컴포넌트 내부에서 속성들을 배치하는 방법이다.
다른 점은 위의 TodoItem
에서 제네릭 타입의 정의와 li
에 배치되는 속성값들에 차이가 있다.
import React from "react"; import Todo from "./todo"; const TodoItem: React.FC<{ todo: Todo }> = (props) => { return <li>{props.todo.text}</li>; }; export default TodoItem;
위처럼 제네릭에 todo
라는 prop
에 Todo
를 타입으로 지정해주고, li
내부의 텍스트를 Todo
가 담고있는 text
로 받아오게 하면 된다.
Todos
컴포넌트의 코드는 아래와 같이 바꿔주자.
import React from "react"; import Todo from "../models/todo"; import TodoItem from "../models/todoitem"; const Todos: React.FC<{ items: Todo[] }> = (props) => { return ( <ul> {props.items.map((item) => ( <TodoItem key={item.id} todo={item} /> ))} </ul> ); }; export default Todos;
전달해줘야하는 건 Todo
그 자체이므로 item
을 보내주되, key
값은 생성해줄 필요가 있으므로 해당 값만 따로 이쪽에 배치해주도록 하면 된다.
2️⃣ 이벤트 객체의 타입 정의
이제 유저가 입력할 입력 폼을 만들고 타입을 지정해보려고 한다. 아래와 같이 NewTodo 컴포넌트 만들고 입력 폼을 생성해봤다.
const NewTodo = () => { const submitHandler = (e) => { // 에러: 'e' 매개 변수에는 암시적으로 'any' 형식이 포함됩니다. e.preventDefault(); }; return ( <form onSubmit={submitHandler}> <label htmlFor="text">Todo text</label> <input type="text" id="text" /> <button>Add Todo</button> </form> ); }; export default NewTodo;
위와 같이 코드를 입력할 경우, 매개변수인 e
의 타입이 암시적으로 any
타입으로 지정된다는 경고 메시지가 출력된다.
JavaScript였다면 크게 신경쓸 일은 아니지만, TypeScript는 이런 점에 민감하기 때문에 타입을 반드시 지정해줘야 한다.
React를 공부하면서 저 e
라는 매개변수에는 이벤트 객체가 들어오게 되고, 그 이벤트 객체를 조사해보면 다양한 속성과 메소드들이 존재하고 있다는 것을 알 수 있다.
이 역시 일일이 다 타입을 지정할 수는 없는 노릇인데, 어떻게하면 저 이벤트 객체 타입을 손쉽게 정의내릴 수 있을까?
방법은 React에서 지원해주는 타입을 이용해주면 된다. 아래의 코드를 보자.
const NewTodo:React.FC = () => { const submitHandler = (e: React.FormEvent) => { e.preventDefault(); }; return ( <form onSubmit={submitHandler}> <label htmlFor="text">Todo text</label> <input type="text" id="text" /> <button>Add Todo</button> </form> ); }; export default NewTodo;
앞서 컴포넌트에 타입을 선언할 때, 함수형 컴포넌트라면 우리는 타입을 React.FC
로 정의를 내렸다.
이는 React에서 미리 정의해둔 타입이 있기 때문에 가능한 일인데, 이벤트 객체 또한 React가 미리 정의해둔 여러가지 타입이 존재한다.
여기서는 form
태그에 있는 submit
이벤트를 적용하기 위해 React.FormEvent
를 이벤트 객체의 타입으로 정의했다.
React에서는 다양한 종류의 이벤트 객체 타입을 지원해주고 있다.
FormEvent
뿐만 아니라 드래그와 관련한 DragEvent
, 클릭 등의 마우스와 관련한 MouseEvent
, 그 밖에도 TouchEvent
, FocusEvent
등 다양한 이벤트 타입이 존재한다.
향후 이렇게 요소에 이벤트를 배치할 때, 어떤 이벤트를 사용하느냐에 따라서 이벤트 객체에 내릴 타입 정의가 달라질 수 있으니 자신이 사용하는 이벤트를 잘 알아두고 타입을 정의하도록 하자.
3️⃣ HTML 요소의 타입 지정 및 !
과 ?
연산자
이제 input 요소에 입력한 값을 submit 이벤트가 받아오도록 하는 내용을 작성해보려고 한다. 보통 이 경우에 useState와 onChange 이벤트를 이용한 방법도 있지만 여기서는 useRef를 이용해서 코드를 작성하겠다.
useRef를 사용할 경우, useRef의 제네릭에 어느 요소를 우리가 참고하고 싶은지 타입을 정의해줘야 한다.
따라서 아래와 같이 코드를 작성하자.
import { useRef } from "react"; const NewTodo: React.FC = () => { const todoTextInputRef = **useRef<HTMLInputElement>(null);** const submitHandler = (e: React.FormEvent) => { e.preventDefault(); }; return ( <form onSubmit={submitHandler}> <label htmlFor="text">Todo text</label> <input type="text" id="text" ref={todoTextInputRef} /> <button>Add Todo</button> </form> ); }; export default NewTodo;
input
은 HTMLInputElement
, h1
은 HTMLHeadElement
, p
는 HTMLParagraphElement
, button
은 HTMLButtonElement
등 TypeScript에서 모든 DOM 요소들은 미리 정의된 타입을 가지고 있다.
우리가 useRef
에서 참고하고자 하는 것은 input
이므로, 여기서는 HTMLInputElement
를 타입으로 정의해줬다.
또한 useRef
선언 시에는 당장 참고할 값을 지정하지 않았으므로, 초기값을 null
로 지정해줬다.
자, 이제 input
의 ref
prop에 useRef
를 지정한 변수 todoTextInputRef
를 배치하면 useRef
가 정상적으로 적용될 것이다.
다음으로 유저가 값을 입력하고 엔터 키를 누르든 Add Todo 버튼을 누르든 하면 값을 검증하는 로직을 아래 코드와 같이 추가해봤다.
import { useRef } from "react"; const NewTodo: React.FC = () => { const todoTextInputRef = useRef<HTMLInputElement>(null); const submitHandler = (e: React.FormEvent) => { e.preventDefault(); const enteredText = todoTextInputRef.current?.value; // useRef를 통해 input 요소의 현재값으로 초기화한 변수, enteredText if (enteredText?.trim().length === 0) { // 해당 변수의 길이가 0일 때, 즉 빈값이면 에러를 출력하도록 한다. return; } }; return ( <form onSubmit={submitHandler}> <label htmlFor="text">Todo text</label> <input type="text" id="text" ref={todoTextInputRef} /> <button>Add Todo</button> </form> ); }; export default NewTodo;
변수 enteredText
를 통해 현재 input
요소의 current value
를 참고하도록 초기화해줬다.
이 때 해당 변수의 코드를 작성하면 자동으로 current
뒤에 ?
가 붙는 것을 볼 수 있다.
이는 TypeScript가 todoTextInputRef
가 참조하는 input
의 current value
에 값이 없을 경우를 대비해서 자동으로 ?
를 붙게 해줬다.
TypeScript에서의 이 ?
는 두 가지 용도로 쓰인다.
선택적 프로퍼티: 물음표가 붙은 값은 포함해도 되고, 포함되지 않아도 된다.
import React from "react"; import Todo from "./todo"; const TodoItem: React.FC<{ todo: Todo, name?: string }> = (props) => { // 여기서의 name은 string 타입으로 지정은 되어 있으나, 꼭 필요한 값이 아님을 가리킨다. return <li>{props.todo.text}</li>; }; export default TodoItem;
옵셔널 체이닝: JavaScript의 옵셔널 체이닝을 가리키며 프로퍼티의 타입이
null
이나undefined
가 올 수 있는 경우가 있음을 알려준다.- 변수
enteredText
에 붙은?
가 이 옵셔널 체이닝이다.
- 변수
그럼 ?
가 아닌 !
는 TypeScript에서 어떤 의미로 쓰일까?
우선 JavaScript의 논리 연산자로서 false
의 의미를 담고 있는 것은 물론 있지만 위의 옵셔널 체이닝과 관련해 반대적인 의미를 가진 연산자로서도 쓰인다.
null
아님을 주장하는 연산자: Not Null 어선셜(assertion) 연산자라고도 말한다. 받아오는 값은null
이 아니며 할당된 값이 반드시 존재한다고 주장한다.type Person { firstName: string | null, lastName: string; } let human: Person; human = { firstName: null, lastName: "Mac" } console.log(human.firstName); // 이 경우, firstName의 값은 null로 받아와지므로 해당 값이 null일 수 있다 경고를 출력한다. human = { firstName: "Donald", lastName: "Mac" } console.log(human!.firstName); // 이 경우, firstName은 반드시 null이 아닌 할당된 값이 존재한다는 컴파일러에게 전달해준다.
확정 할당 주장:
!
를 붙인 변수나 객체는 반드시 값을 가지고 있음을 주장한다.let x: number console.log( x + x ); // 이 경우, x를 사용하기 전에 x에 값을 할당하라는 에러를 띄워준다. let x!: number // 이 경우, x는 반드시 값을 가지고 있음을 컴파일러에게 전달한다. console.log( x + x ); // 컴파일러는 x에 값이 반드시 있다고 생각하고 위의 코드에 // x에 값을 할당하라는 에러를 띄우지 않는다.
이처럼 TypeScript에서는 ?
와 !
를 통해 좀 더 해당 타입이 받아오는 값들을 명확하게 해줄 수 있다.
4️⃣ 함수 타입을 정의하기
이제, 입력한 Todo
값을 받아와 리스트로 보내줘 화면 상에 출력시키는 내용을 구현해보고자 한다.
우선은 이전의 NewTodo
에서 정상적으로 값을 받아왔다면, 상위 컴포넌트인 App
으로 보내주기 위한 코드를 아래와 같이 작성했다.
import { useRef } from "react"; const NewTodo: React.FC<{ **onAddTodo: (text: string) => void** }> = (props) => { const todoTextInputRef = useRef<HTMLInputElement>(null); const submitHandler = (e: React.FormEvent) => { e.preventDefault(); const enteredText = todoTextInputRef.current?.value; if (enteredText?.trim().length === 0) { return; } **props.onAddTodo(enteredText!);** // 위에서 말한 대로 enteredText에는 값이 반드시 있다는 것을 명확하기 위해 !를 붙였다. }; return ( <form onSubmit={submitHandler}> <label htmlFor="text">Todo text</label> <input type="text" id="text" ref={todoTextInputRef} /> <button>Add Todo</button> </form> ); }; export default NewTodo;
상위 컴포넌트로 값을 보내주기 위한 함수로서 onAddTodo
를 생성했다.
보내줄 값은 유저가 입력한 내용이므로 enteredText
가 들어간다.
하지만, onAddTodo
는 prop
에 없는 메소드이기 때문에 우리는 제네릭을 통해서 onAddTodo의 타입을 지정해줄 필요가 있다.
앞서 함수의 타입은 매개변수와 반환값의 타입을 지정한다고 했다.
여기서 유저가 입력한 값인 enteredText
는 문자열이므로 onAddTodo
의 매개변수인 text
는 문자열로 정의했으며, 단지 enteredText
를 가져다 줄 뿐인 내용이므로 별도의 반환값은 필요 없기에 return
이 없다는 의미에서 void
를 반환값의 타입으로 정의했다.
5️⃣ TypeScript에서 useState의 사용
자, 그러면 App
컴포넌트로 돌아가서 해당 컴포넌트가 onAddTodo
를 받아올 수 있도록 수정하자.
import NewTodo from "./components/NewTodo"; import Todos from "./components/Todos"; import Todo from "./models/todo"; function App() { const todos = [new Todo("Learn React"), new Todo("Learn TypeScript")]; const addTodoHandler = (todoText: string) => {}; return ( <div className="App"> <NewTodo onAddTodo={addTodoHandler} /> <Todos items={todos} /> </div> ); } export default App;
NewTodo
에서 onAddTodo
의 타입을 정의했기 때문에 App
컴포넌트에서는 NewTodo
의 onAddTodo prop
을 요구할 것이다.
앞서 onAddTodo
는 prop
의 메소드, 즉 함수로 정의했으므로 onAddTodo
가 쓰여질 함수인 addTodoHandler
를 생성하고, onAddTodo의 매개변수 타입을 string
를 받았으니 여기서도 매개변수의 타입을 동일하게 string
으로 받게 했다.
이제, useState
를 통해서 NewTodo
를 통해 입력한 값을 배열 속에 넣어 리스트로 출력하는 내용을 작성해볼 것이다.
**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([]);** const addTodoHandler = (todoText: string) => {}; return ( <div className="App"> <NewTodo onAddTodo={addTodoHandler} /> <Todos items={todos} /> </div> ); } export default App;
useState
를 사용하는 것 자체는 기존 React에서 쓰는 것과 동일하다.
다만, 다른 점이 있다면 initialState
, 즉 초기값에 관한 부분인데 유저가 입력한 Todo 내용이 추가될 때마다 배열에 넣기 위해 현재 초기값으로 빈 배열을 적용했다.
하지만, 여기서 todos
를 확인하면 todos
의 타입이 array
가 아닌 never
라는 타입으로 정의되는 걸 볼 수 있다.
never
타입은 값이 없음을 나타내는데, 여기서 initialState
는 빈 배열로 넣어지면서 값이 없다고 판단해 타입을 never
로 정의해버리면서 todos
에는 어떠한 값도 넣을 수 없게 만든다.
이로 인해 setState 함수를 사용해 새로운 값을 넣고 싶어도 타입 에러가 발생하므로 never
타입이 적용되지 않도록 수를 써야 한다.
다행히 React에서는 이러한 점을 고려하여 useState
에 제네릭을 쓸 수 있도록 허용해 타입이 유연하게 설정될 수 있도록 했다.
이 제네릭을 이용해 우리가 받아올 배열이 class
로 정의한 Todo
타입이 들어오게 정의해주자.
const [todos, setTodos] = useState**<Todo[]>**([]); // 이제 todos는 Todo[] 타입으로 정의된다.
이제, 작성한 Todo를 배열에 넣어 리스트로 나오게 만들어주자.
함수 addTodoHandler
를 통해 NewTodo
에서 입력한 값을 class
Todo
의 형식에 따른 객체로 만들어주고 이를 setState 함수인 setTodos
를 통해 배열에 추가했다.
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([...todos, newTodo]);** }; return ( <div className="App"> <NewTodo onAddTodo={addTodoHandler} /> <Todos items={todos} /> </div> ); } export default App;
setTodos
에 기존 배열인 todos
를 전개 구문을 활용해 새로운 값인 newTodo
가 추가되도록 했다.
이제 만들어진 내용을 테스트하면, 아래와 같이 잘 작동된다.
⚠️ 전개 구문이 아닌 .concat()
을 통해 배열을 만드는 방법
위의 방법은 이전에 전개 구문을 학습한 적이 있고, 프로젝트를 진행할 때 자주 사용했기 때문에 해당방법으로 작성할 수 있었다.
그러나, 강의에서는 전개 구문 방법이 아닌 .concat()
이라는 배열의 메소드를 활용하여 배열을 새로 만드는 형태로 코드를 만들었으므로 해당 내용을 여기에도 기록하고자 한다.
우선 .concat()
메소드에 대해 설명하자.
.concat()
는 배열의 메소드이며, 인자로 받은 배열이나 값을 기존 배열에 합쳐 새로운 배열로 만들어주는 메소드이다.
const alpha = ['a', 'b', 'c']; const numeric = [1, 2, 3]; alpha.concat(numeric); // 결과: ['a', 'b', 'c', 1, 2, 3]
이처럼 기존 배열에 .concat()
의 인자값을 받아와 기존 배열과 합쳐 새로운 배열을 만들어낸다.
이 메소드를 이용하여 기존 todos
배열에 newTodo
를 추가하는 방법은 아래와 같이 작성된다.
setTodos((prevTodos) => { return prevTodos.concat(newTodo); });
setTodos
의 매개변수로 기존 배열을 받아오는 prevTodos
에 .concat()
메소드르 사용하여 새로운 값인 newTodo
를 추가하게 해줬다.
이렇게 작성하면 앞에서 구조 전개 문법으로 만든 새로운 배열과 동일한 배열을 만들 수 있다.
📁 참고 자료
【한글자막】 React 완벽 가이드 2024 with React Router & Redux