오늘은 타입스크립트 기반 리액트 TO DO 앱을 만들어 보고자 합니다.
아주 자세한 리액트 TODO앱의 로직보다는, 타입스크립트를 처음 적용하는 경우 고려할 사항을 정리해보았습니다.
이전 포스팅에서 VITE 환경에서 리액트 타입스크립트를 구성해보았으니, 참고하시기 바랍니다.
스캐폴딩 후 불필요한 파일 삭제
위에서 생성된 기본 스켈리톤 구조에서 쓸떼없는 것은 다 지워줍니다.
public > *.svg
assets > *.svg
app.tsx 의 각종 태그 및 useState 구문 등
App.tsx
App 앱 클래스는 두가지 방식으로 타입스크립트로 나타낼 수 있습니다.
Arrow 방식이나
const App: React.FC = () => {
return <div></div>;
};
또는 function 방식으로
function App(): JSX.Element {
return <div></div>;
}
Main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
React.StrictMode 란?
리액트 앱에서 제공하는 레퍼 컴포넌트인데, 렌더링을 2번 호출 함으로써 앱의 잠재적인 문제를 식별(테스트)하는데 도움을 줍니다.
하지만 가끔은 이로 인해 정상적인 로직임에도 2번 실행되면서 오히려 테스트에 방해가 될 때가 있습니다.
그래서 이런 경우를 싫어한다면 지우고 해도 앱을 실행하는데 큰 문제가 없습니다.
ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
)
TodoList.tsx 생성
먼저 src 하위에 Components 폴더 생성한 후 하위에 TodoList.tsx 생성합니다.
import React from "react";
const TodoList = (props) => {
return (
<>
<h2>TodoList-Title</h2>
</>
);
};
export default TodoList;
만약 아래와 같은 오류가 발생한다면 ESLint 옵션 등으로 해당 메시지를 없애줍니다. 리액트에서는 리액트용 컴포넌트임을 표현하기 위해 관행적으로 사용하는 경우가 많습니다.
React 선언은 되었지만 해당 값이 읽히지는 않았습니다
참고로 타입스크립트 리액트에서는 다음과 같이 하니 해결되었습니다.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import React from "react";
...
자바스크립트 같은 경우에는
// eslint-disable-next-line
TodoList 함수의 타입스크립트 리턴값 정의
React.FC 로 정의합니다. FC란 Fcuntional Component 를 이야기 합니다.
import React from "react";
const TodoList: React.FC = (props) => {
return (
<>
<h2>TodoList-Title</h2>
</>
);
};
export default TodoList;
App 컴포넌트에서 TodoList 컴포넌트 호출
부모 컴포넌트에서 items를 받아 리스트로 출력하는 컴포넌트 입니다.
const TodoList: React.FC = (props) => {
return (
<>
<h1>My Todos</h1>
<ul>
{props.items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</>
);
};
하지만, 타입스크립트 리액트에서는 items의 타입을 알 수 없기 때문에 아래와 같은 오류가 발생합니다.
아래와 같이 generic type 선언으로 해결할 수 있습니다.
interface TodoListProps {
items: { id: string; text: string }[];
}
// generic type
const TodoList: React.FC<TodoListProps> = (props) => {
return (
<>
<h1>My Todos</h1>
<ul>
{props.items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</>
);
};
즉, props로 넘어오는 파라메터 오브젝트는 TodoListProps 타입의 프로퍼티로 받을 것임을 지정합니다.
부모로부터 넘어오는 매개변수 타입 역시 동일해야 합니다.
const TodoList: React.FC<TodoListProps> = (props) => ...
....
App.tsx에서 TodoList.tsx로 데이터 props 넘겨주기
TodoList.tsx에서 TodoListProps 프로퍼티를 지정한 이후로는, App.tsx에서도 에러가 사라졌습니다.
todos 배열을 선언하여 TodoList에 items props로 값을 넘겨 줍니다.
function App(): JSX.Element {
const todos = [
{ id: "t1", text: "Finish the course" },
{ id: "t2", text: "Cook dinner" },
];
return (
<div className="App">
<TodoList items={todos} />
</div>
);
}
New Todo 입력 화면 생성
src/components 하위에 NewTodo.tsx 파일을 생성합니다.
import React from "react";
const NewTodo: React.FC = (props) => {
console.log("<NewTodo.jsx> - props: ", props);
return (
<>
<form>
<div>
<label htmlFor="todo-input">Todo Text</label>
<input
type="text"
id="todo-input"
/>
</div>
<button type="submit">TODO 추가</button>
</form>
</>
);
};
export default NewTodo;
생성한 NewTodo.tsx 파일을 App.tsx에 넣어줍니다.
NewTodo 컴포넌트를 import 한 후 TodoList 컴포넌트 밑에 추가해 주었습니다.
...
import NewTodo from "./components/NewTodo";
...
function App(): JSX.Element {
...
return (
<div className="App">
<TodoList items={todos} />
<NewTodo />
</div>
);
}
React.FormEvent
파라메터 e의 타입으로 React.FormEvent 를 명시적으로 지정하게 되면, TypeScript가 이 매개변수가 이벤트 객체를 뜻하는 것으로 인식합니다.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
enteredText.value = "";
};
e.preventDefault(); 처리하여 화면이 갱신되는 것을 막아줍니다.
getElementBy로 값 읽어오기
다음 처럼 getElementById를 통해서 값을 가져와서 처리할 수 있습니다.
const handleSubmit = (e: React.FormEvent) => {
const enteredText = document.getElementById(
"todo-input"
)! as HTMLInputElement;
console.log(enteredText.value);
enteredText.value = "";
};
느낌표(!)는 not-null 단언 연산자라고 하는데, 이 연산자의 의미는, 엘리먼트를 반드시 찾을 것으로 가정하지만, 이것이 null 혹은 undefined 같이 falsy value 이어도 예외를 발생시키지 않도록 typescript에 알려주는 역할을 합니다.
useRef 로 값 읽어오기
useRef Hook을 선언한 후 textInputRef 변수를 선언합니다.
이때 Typescript 가 textInputRef가 참조할 DOM 요소 타입을 HTMLInputElement 로 인식할 수 있도록 Generic 타입으로 지정해 줍니다.
import React, { useRef } from "react";
const NewTodo: React.FC = (props) => {
const textInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const enteredText = textInputRef.current!.value;
console.log(enteredText);
};
...
textInputRef.current!.value 의 경우, textInputRef.current 오브젝트에는 항상 not null 로 인식하도록 타입스크립트에 알려주기 위해 not-null 연산자 ! 가 사용되었습니다.
HTML input elelemt에 useRef 를 아래와 같이 연결합니다.
<form onSubmit={handleSubmit}>
...
<input
type="text"
id="todo-input"
ref={textInputRef}
/>
...
</form>
미리 기술한 form onSubmit 이벤트에 function 을 입력해 줍니다.
<form onSubmit={handleSubmit}>
...
</form>
컴포넌트 간 데이터 교환
NewTodo 와 TodoList 컴포넌트는 Sibling 관계 이므로 부모 컴포넌트인 App 컴포넌트를 통해서 데이터를 전달(Pass) 해야합니다.
즉, NewTodo 에서 신규로 만들어진 Todo 내역은 App 컴포넌트의 Todo List 에 추가하여 다시 TodoList 컴포넌트로 전달하면 됩니다.
부모 컴포넌트에서 Add 이벤트 생성 후 NewTodo 로 전달
부모 컴포넌트 App 컴포넌트에서 handleAddTodo 함수를 만들어 함수의 포인터를 onAddTodo로 전달합니다.
function App(): JSX.Element {
const todos = [
{ id: "t1", text: "Finish the course" },
{ id: "t2", text: "Cook dinner" },
];
const handleAddTodo = (text: string) => {
//
};
return (
<div className="App">
<TodoList items={todos} />
<NewTodo onAddTodo={handleAddTodo} />
</div>
);
}
하지만, 이때 타입스크립트는 onAddTodo의 타입을 알 수 없으므로 다음과 같은 에러가 발생합니다.
{ onAddTodo: (text: string) => void; }' 형식은 'IntrinsicAttributes' 형식에 할당할 수 없습니다.
'IntrinsicAttributes' 형식에 'onAddTodo' 속성이 없습니다.ts(2322)
NewTodo 컴포넌트에 handler 함수 Generic 타입 선언
다음과 같이 NewTodoProps type을 정의한 후, generic 타입으로 props 타입을 지정했습니다.
type NewTodoProps = {
onAddTodo: (todoText: string) => void;
};
const NewTodo: React.FC<NewTodoProps> = (props) => {
const textInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const enteredText = textInputRef.current!.value;
props.onAddTodo?.(enteredText);
textInputRef.current!.value = "";
};
이 때 type구문으로 작성된 NewTodoProps 는 다음과 같이 interface 키워드를 통해서도 작성할 수 있습니다.
interface NewTodoProps = {
onAddTodo: (todoText: string) => void;
};
타입스크립트에서 interface와 type의 차이
type은 타입의 별칭(alias)을 생성하여 기존에 이미 만들어 둔 타입을 재정의 하여 간결하게 사용할 수 있습니다. 또 유니온(|) 및 인터섹션(&) 연산자를 사용하여 2개 이상의 타입을 합쳐서 확장이 가능합니다.
type Person = {
name: string;
age: number;
};
type Student = Person {
studentID: string;
};
interface 는 주로 객체의 구조를 정의하는데 사용되며, 속성으로 정의된 사항은 모두 필수로 구현해야 합니다. 확장의 경우에는 extends 키워드를 통해 하위 인터페이스에 상위 인터페이스를 포함할 수 있습니다.
interface Person {
name: string;
age: number;
}
interface Student extends Person {
studentID: string;
}
interface나 type의 경우 여러 파일에 산재되어 있는 내역을 model 등의 파일로 모아서 통합 관리하고 가져다 쓰는 방식을 주로 사용합니다.
import { Person } from "./person.model"
옵셔널 체이닝 연산자 : ?
옵셔널 체이닝 연산자는 객체에 접근시, 어떠한 이유에서 null 등 존재하지 않는 경우에도 에러를 발생시키지 않고 undefinded 값을 반환토록 합니다.
props.onAddTodo?.(enteredText);
즉, 이 연산자를 연결하여 객체에 접근하면 null 혹은 undefined 로 인한 예외 발생을 예방하여 안전하게 속성 혹은 메소드를 처리할 수 있습니다.
useState Hook 에 Generic type 정의
기존 더미 todos 를 없애고 다음과 같이 App 컴포넌트를 수정합니다.
interface Todo {
id: string;
text: string;
}
function App(): JSX.Element {
const [todos, setTodos] = React.useState<Todo[]>([]);
const handleAddTodo = (text: string) => {
setTodos((prevTodos) => {
return prevTodos.concat({ id: Math.random().toString(), text: text });
});
};
...
Todo 로 정의한 Interface를 선언하고 todo 리스트 관리용 State 에 해당 인터페이스의 배열로 정의해 줍니다.
const [todos, setTodos] = React.useState<Todo[]>([]);
이제 추가 버튼을 누르면 정상적으로 To do 내역이 추가됨을 확인할 수 있습니다.
이상으로 타입스크립트를 활용하여 TODO 리스트 리액트 앱 기본 로직을 정리해 보았습니다.
그 밖에 도움 되는 글
리액트 라디오 박스 버튼 구현하기(feat.MUI 컴포넌트)