🖥️

12장 리액트 with 타입스크립트

 

12.1 프로젝트 세팅하기

12.1.1 새로운 리액트 프로젝트 생성

기존 create react app 명령어에서 —template typescript를 추가하여 타입스크립트가 포함된 리액트 프로젝트를 생성할 수 있습니다.

1) npm을 사용하는 경우

npx create-react-app tsreactapp --template typescript
node.js의 기본적인 패키지 관리자인 npm을 사용하여 프로젝트를 생성하는 방법입니다.
 

2) yarn을 사용하는 경우

yarn create react-app tsreactapp --template typescript
yarn 패키지 관리자는 facebook, google 등의 기업들이 공동으로 개발한 패키지 관리자로 npm보다 더 빠른 설치 속도와 안정성 및 보안 측면에서 개선된 성능을 제공합니다.
 
(1) yarn 설치방법
npm install -g yarn
-g 명령어는 해당 프로젝트뿐만 아니라 시스템 전역에서 해당 패키지를 사용 가능하도록 합니다.
 

12.1.2 기존 리액트 프로젝트에 타입스크립트 마이그레이션

기존의 자바스크립트 리액트 프로젝트를 타입스크립트로 변환하는 과정입니다.

1) 패키지 설치

(1) npm을 사용하는 경우
npm install --save typescript @types/node @types/react @types/react-dom @types/jest
 
(2) yarn을 사용하는 경우
yarn add typescript @types/node @types/react @types/react-dom
@types/node
node.js의 타입 정의와 관련된 내장 모듈과 타입 체킹과 동적으로 타입 자동 완성 기능을 제공합니다.
@types/react
react에서의 타입 정의와 관련된 타입 정의 기능을 제공합니다. 컴포넌트, 이벤트 핸들러, props, hook 등에 타입스크립트를 사용할 수 있게 합니다.
@types/react-dom
reactDOM에서 타입 정의와 관련된 타입 정의 기능을 제공합니다. 주로 DOM 조작과 렌더링과 관련하여 타입 정보를 제공하고 타입 체킹을 합니다.
 

2) 파일 확장자 변경

src 폴더 내의 App.js를 App.tsx, index.js를 index.tsx로 확장자를 변경합니다. 이는 JSX에 타입스크립트를 함께 사용한다는 것을 명시하기 위해서 변경합니다.
import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render(<App />);
또한 index.tsx의 코드의 document.getElementById가 null을 반환할 수도 있기 때문에 as HTMLElement 타입 단언을 통해 반환 타입을 명시적으로 선언해 주어야 런타임 오류를 방지할 수 있습니다.
 

3) 필수 컴파일러 설정

(1) tsconfig.json 파일 생성
tsc --init
리액트 프로젝트에서 타입스크립트 컴파일러 옵션을 사용하기 위해 tsconfig.json 파일을 명령어를 통해 생성합니다.
 
(2) 필수 컴파일러 옵션 설정
{ "compilerOptions": { "target": "es6", "module": "esnext", "jsx": "react-jsx", "moduleResolution": "node", "esModuleInterop": true } }
target
자바스크립트 코드가 실행될 컴파일러 버전을 명시합니다.
module
리액트와 함께 사용될 모듈 번들러의 시스템을 명시합니다. webpack 모듈 번들러 시스템을 사용할 경우 esnext를 사용합니다.
jsx
JSX 코드를 처리하는 방식을 설정합니다. 리액트를 사용할 경우 react-jsx를 사용합니다.
moduleResolution, esModuleInterop
node.js와 자바스크립트 간의 모듈 호환성을 위해 설정합니다.
 

4) 기본 폴더 구조 및 코드 구성

기존 리액트에 타입스크립트를 사용하기 앞서 기본적인 폴더 구조 및 코드를 구성합니다. 코드 예시는 todolist이며, 폴더 구조는 다음과 같습니다.
프로젝트 컴포넌트 구조프로젝트 컴포넌트 구조
프로젝트 컴포넌트 구조
(1) App.tsx
import React from 'react'; import Todo from './Todo'; function App() { return ( <div> <Todo /> </div> ); } export default App;
 
(2) Todo.tsx
import React, { useState } from "react"; import TodoList from "./TodoList"; import AddTodo from "./AddTodo"; import styled from "styled-components"; export default function Todo() { const [idCount, setIdCount] = useState(3); const [todoList, setTodoList] = useState([ { id: 0, content: "치킨 먹기", done: false }, { id: 1, content: "피자 먹기", done: false }, { id: 2, content: "마라탕 먹기", done: false }, ]); return ( <Container> <Wrapper> <Title>My Todolist</Title> <AddTodo todoList={todoList} setTodoList={setTodoList} idCount={idCount} setIdCount={setIdCount} /> <TodoList todoList={todoList} setTodoList={setTodoList} /> </Wrapper> </Container> ); } const Wrapper = styled.div``; const Container = styled.main` display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 100vw; min-height: 100vh; background-color: #f6f7f9; `; const Title = styled.h1` color: #7990ca; `;
 
(3) TodoList.tsx
import React from 'react'; import ToggleTodo from './ToggleTodo'; import DeleteTodo from './DeleteTodo'; import styled from 'styled-components'; export default function TodoList({ todoList, setTodoList }) { return ( <> {todoList.map((todo) => { return ( <Wrapper key={todo.id}> <Item> <ToggleTodo id={todo.id} todoList={todoList} setTodoList={setTodoList} /> <Content checked={todo.done}>{todo.content}</Content> <DeleteTodo id={todo.id} done={todo.done} setTodoList={setTodoList} /> </Item> </Wrapper> ); })} </> ); } const Wrapper = styled.div` display: flex; font-size: 30px; `; const Content = styled.p` text-decoration: ${({ checked }) => checked && 'line-through'}; `; const Item = styled.div` display: flex; align-items: center; justify-content: space-between; width: 100%; color: #7990ca; margin-right: auto; `;
 
(4) AddTodo.tsx
import React, { useState } from 'react'; import styled from 'styled-components'; export default function AddTodo({ todoList, setTodoList, idCount, setIdCount }) { const [inputValue, setInputValue] = useState(''); const onChangeInput = (event) => { setInputValue(event.target.value); }; const onAddTodo = () => { setTodoList([...todoList, { id: idCount, content: inputValue, done: false }]); setIdCount((prevState) => prevState + 1); setInputValue(''); }; return ( <Wrapper> <Input value={inputValue} onChange={onChangeInput} /> <Button disabled={!inputValue} $value={inputValue === ''} onClick={onAddTodo}> 추가 </Button> </Wrapper> ); } const Wrapper = styled.div` display: flex; align-items: center; gap: 5px; padding-bottom: 20px; `; const Input = styled.input` padding: 0 15px; border: none; background-color: transparent; border-bottom: 1px solid #b5c3df; color: #7990ca; outline: none; &:focus { border-width: 2px; } `; const Button = styled.button` padding: 3px 15px; border: none; background-color: ${({ $value }) => ($value ? "#7990CA" : "#939eb6")}; color: #f6f7f9; cursor: pointer; border-radius: 3px; `;
 
(5) ToggleTodo.tsx
import React from 'react'; import styled from 'styled-components'; export default function ToggleTodo({ id, todoList, setTodoList }) { const onToggleTodo = () => { setTodoList( todoList.map((todo) => { if (todo.id === id) { return { ...todo, done: !todo.done }; } else { return todo; } }) ); }; return <Input type="checkbox" onChange={() => onToggleTodo()} />; } const Input = styled.input` cursor: pointer; `;
 
(6) DeleteTodo.tsx
import React from 'react'; import styled from 'styled-components'; export default function DeleteTodo({ id, done, setTodoList }) { const onDeleteTodo = () => { setTodoList((prevState) => prevState.filter((todo) => todo.id !== id)); }; return <Button $isActive={done} onClick={() => onDeleteTodo()}>삭제</Button>; } const Button = styled.button` padding: 3px 15px; border: none; background-color: ${(props) => (props.$isActive ? "#7990ca" : "#939eb6")}; color: #f6f7f9; border-radius: 3px; cursor: pointer; &:hover { background-color: #7990ca; } `;
 

12.2 State

12.2.1 useState 훅

리액트 프로젝트에는 리액트 훅(React Hooks)이라는 편리한 도구가 있습니다. 리액트 훅(Hooks) 중에서 가장 기본적인 훅이자 중요한 useState를 중점으로 타입스크립트를 리액트에 적용하는 방법을 살펴보겠습니다.

1) 상태의 종류

리액트 애플리케이션에서 상태(state)는 시간이 지나면서 변할 수 있는 동적인 데이터이며, 값이 변경될 때마다 컴포넌트 렌더링 결과에 영향을 줍니다. 리액트 앱 내의 상태는 지역 상태(Local State), 전역 상태(Global State), 서버 상태(Server State)로 분류할 수 있습니다. 지역 상태는 주로 React 훅인 useSate를 사용하여 관리합니다. 전역 상태는 앱 전체에서 공유하는 상태를 의미하며 리액트 내부 API만을 사용하여 상태를 관리할 수 있지만 성능 문제와 상태의 복잡성으로 인해 Redux, MobX, Recoil 같은 외부 상태 라이브러리를 주로 활용합니다. 서버 상태는 유저 정보나 사진과 같이 외부 서버에 저장해야 하는 상태를 의미하며 주로 react-query, SWR과 같은 외부 라이브러리를 사용합니다.

2) 리액트 훅 (React Hooks)

리액트 16.8 버전에 훅이 도입되면서 함수형 컴포넌트에서도 컴포넌트의 생명주기에 맞춰 로직을 실행할 수 있게 되었습니다. 이로써 재사용성이 향상되었고, 코드 분할을 통한 단위 테스트가 쉬워졌으며, 사이드 이펙트와 상태를 관심사에 맞게 분리하여 구성할 수 있게 되었습니다. 리액트 훅에는 useState, useEffect, useMemo, useCallback, useRef 등 다양하지만, 이 절에서는 가장 기본적이면서 중요한 useState 훅에 타입스크립트 적용하는 방법을 중점적으로 다룹니다.

3) useState

useState는 컴포넌트에 상태 변수를 추가할 수 있는 리액트 훅입니다. 이 훅은 컴포넌트의 상태를 간편하게 생성하고 변경할 수 있는 기능을 제공합니다. 오늘이 무슨 요일인지 알려주는 컴포넌트가 있다면, state는 day가 될 수 있습니다. useState의 인자로 초깃값을 넣어주면 현재 상태 값인 state 변수와 상태를 변경할 수 있는 setState 함수를 요소(element)로 가지는 배열을 반환합니다.
// useState 사용 방법 const [state, setState] = useState(초기값);
아래 예시는 클릭 시 상태(day)가 변경되는 간단한 Button 컴포넌트입니다.
// 예시 import { useState } from "react"; export default function Button() { const [day , setDay] = useState('sunday'); console.log(day); // (초기): sunday, (버튼 클릭 후): friday return ( <button onClick={() => setDay('friday')}>버튼</button> ); }
setDay 함수를 통해 상태 day를 변경하면 리액트는 페이지(화면)에 해당 컴포넌트를 다시 렌더링 합니다. 다시 말해, 상태가 변경될 때마다 컴포넌트가 실시간으로 업데이트됩니다.
 

12.2.2 HTMLElement 타입

웹 사이트는 HTML 또는 XML 문서로 구성됩니다. 그리고 문서 객체 모델(Document Object Model, DOM)은 정적 웹 사이트를 기능적으로 작동시키기 위해 브라우저에 의해 구현된 프로그래밍 인터페이스입니다. DOM API를 사용하면 문서의 구조, 스타일, 그리고 내용을 변경할 수 있습니다. 이 강력한 API를 바탕으로 보다 쉽게 동적인 웹 사이트를 개발하기 위해 수많은 프론트엔드 프레임워크 및 라이브러리(jQuery, React, Angular 등)가 개발되었습니다.자바스크립트의 Superset(상위 집합)인 타입스크립트는 DOM API에 대한 타입 정의를 제공합니다. 타입스크립트 프로젝트에서 DOM 조작에 사용될 수 있는 lib.dom.d.ts에 있는 2만여 줄의 타입 중에서 중심이 되는 타입이 바로 HTMLElement 타입입니다. HTMLElement 타입은 타입스크립트에서 div, p, span, a 등과 같은 HTML 요소를 나타내며 웹 페이지의 DOM에서 HTML 요소를 참조할 때 사용한다. 이 타입은 다른 모든 HTML 요소 인터페이스의 기본 인터페이스 역할을 합니다. 예를 들면, div 태그는 HTMLElement를 상속받은 HTMLDivElement 타입이며 p 태그는 HTMLParagraphElement 타입입니다. HTMLElement 타입의 정의는 다음과 같습니다.
interface HTMLElement extends Element, DocumentAndElementEventHandlers, ElementCSSInlineStyle, ElementContentEditable, GlobalEventHandlers, HTMLOrSVGElement { accessKey: string; readonly accessKeyLabel: string; autocapitalize: string; dir: string; draggable: boolean; hidden: boolean; inert: boolean; innerText: string; lang: string; readonly offsetHeight: number; readonly offsetLeft: number; readonly offsetParent: Element | null; readonly offsetTop: number; readonly offsetWidth: number; outerText: string; spellcheck: boolean; title: string; translate: boolean; attachInternals(): ElementInternals; click(): void; addEventListener<K extends keyof HTMLElementEventMap>( type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void; addEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void; removeEventListener<K extends keyof HTMLElementEventMap>( type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions ): void; removeEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions ): void; }
HTMLElement 타입을 선언할 때 as HTMLElement를 사용하여 아래 예시와 같이 타입을 명시적으로 지정할 수 있습니다. 주의할 점은 getElementById 메서드 수행의 결과 해당하는 id의 엘리먼트가 없다면 null이 반환될 수 있습니다.
let mainDiv = document.getElementById('main') as HTMLElement;
투두 리스트 프로젝트의 src > index.tsx 파일에서 App 컴포넌트를 렌더링 할 root를 id로 가지는 HTML 요소를 불러오는 코드에서 HTMLElement 타입이 사용되었습니다.
// index.tsx const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render(<App />);
 

12.2.3 useState에 타입스크립트 적용하기

1) useState 훅의 타입

프로젝트에 타이핑하기 전에 타입스크립트에서 useState의 타입을 어떻게 정의해 두었는지 살펴봅니다. React와 관련된 타입의 정의는 node_modules > @types > react > ts5.0 > index.d.ts에 들어가면 확인할 수 있습니다. useState의 타입 정의는 다음과 같습니다.
type Dispatch<A> = (value: A) => void; type SetStateAction<S> = S | ((prevState: S) => S); function useState<S>( initialState: S | (() => S) ): [S, Dispatch<SetStateAction<S>>]; function useState<S = undefined>( ): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
useState의 타입 정의는 제네릭 타입을 사용해 타입 변수를 지정하고, 튜플 타입을 반환합니다. useState의 반환값인 튜플은 제네릭으로 지정한 타입의 상태 S와 그 상태를 변경할 수 있는 제네릭 형태의 Dispatch 타입 함수를 요소로 가집니다. Dispatch 타입 함수는 다시 매개변수 A의 타입으로 제네릭으로 지정한 setStateActionS 또는 이전 상태 값을 받아 새로운 상태 값을 반환하는 함수인 (prevState: S) ⇒ S가 들어갈 수 있습니다.
 

2) useState 타입 추론

만약 useState에 제네릭 타입을 명시하지 않았다면, 타입스크립트 컴파일러는 useState의 초깃값 타입을 통해 state의 타입을 추론합니다. 예를 들면, useState의 인자로 string 타입의 초깃값을 넣어주면 반환되는 튜플 타입은 [string, Dispatch<SetStateAction<string>>]이 됩니다. 제네릭 타입도 초깃값도 없다면 state의 타입은 undefined로 추론됩니다. 실제로 투두 리스트 프로젝트의 AppTodo 컴포넌트에 사용된 useState의 경우에 제네릭 타입을 명시하지 않았지만 초깃값으로 넣은 빈 문자열(’’)을 통해 타입 변수는 string 타입으로 추론되었습니다.
// AddTodo.tsx const [inputValue, setInputValue] = useState(''); // useState<string>('') (타입 추론) // const inputValue: string // const setInputValue: React.dispatch<React.SetStateAction<string>>
 

3) Todo.tsx 상태 타입 지정

기존 useState에 타입스크립트를 적용하면 강력한 효과를 볼 수 있습니다. 아래 예시와 같이 useState 초깃값에 들어갈 TodoItem 하나를 추가한다고 가정해 보겠습니다. id에 문자열이 들어가고 오타가 발생한 프로퍼티 이름 “contnet”를 가진 객체가 추가된 경우 프로그램은 실행 화면에서 콘텐츠 내용이 표기되지 않을 뿐 아무런 에러도 발생하지 않습니다. 이는 단순한 예시이지만 프로퍼티 값이 계산에 사용되는 경우 예상치 못한 사이드 이펙트가 발생할 수 있습니다.
// Todo.jsx export default function Todo() { const [idCount, setIdCount] = useState(3); const [todoList, setTodoList] = useState([ { id: 0, content: '치킨 먹기', done: false }, { id: 1, content: '피자 먹기', done: false }, { id: 2, content: '마라탕 먹기', done: false }, { id: "third", contnet: "오타!", done: true}, // Error 발생 없음 ]); return ( <Container> <Wrapper> <Title>My Todolist</Title> <AddTodo todoList={todoList} setTodoList={setTodoList} idCount={idCount} setIdCount={setIdCount} /> <TodoList todoList={todoList} setTodoList={setTodoList} /> </Wrapper> </Container> ); }
 
💡
사이드 이펙트 (Side Effect) 사이드 이펙트 또는 부수 효과는 예상하지 못하는 상황에서 문제가 발생하는 것을 의미합니다. 소스 코드 간 의존성이 있는 경우, A 이슈를 수정한 후 다른 이슈가 연달아 발생할 때 사이드 이펙트가 발생했다고 말합니다. React 프로젝트에서는 useEffect 훅을 사용해 사이드 이펙트를 관리합니다. 여기에서 사이드 이펙트는 예상치 못한 문제로써 부정적인 의미가 아닌 렌더링 이후 수행해야 할 작업이라고 볼 수 있습니다. 즉, input(state&props)-output(UI) 이외의 외부 세계와 관련된 값을 조작하는 것을 의미합니다.
순수 자바스크립트 코드에 타입스크립트를 적용하면 잘못 표기된 프로퍼티 명칭에 대한 실수를 잡아낼 수 있습니다. 인터페이스로 정의한 TodoItem 타입을 useState의 상태 변수 타입으로 지정해 봅니다. 초깃값으로 넣어둔 예시의 형태와 같이 투두 리스트의 상태 todoList는 TodoItem 타입의 객체로 구성된 배열 타입입니다. 그리고 TodoItem 타입은 id, content, done으로 총 3 개의 프로퍼티를 가지고 있습니다. 타입스크립트 컴파일러를 통해 string, number, boolean 같은 원시 자료형은 자동으로 타입이 추론되지만 idCount 상태를 관리하는 useState의 상태 타입 변수에 명시적으로 number 타입을 지정해 줬습니다.
// Todo.tsx interface TodoItem { id: number; content: string; done: boolean; } export default function Todo() { const [idCount, setIdCount] = useState<number>(3); const [todoList, setTodoList] = useState<TodoItem[]>([ { id: 0, content: "치킨 먹기", done: false }, { id: 1, content: "피자 먹기", done: false }, { id: 2, content: "마라탕 먹기", done: false }, // { id: "third", contnet: "오타!", done: true}, // Error ]); // ...
이처럼 타입스크립트를 사용하면 기능을 추가하거나 수정할 때 타입스크립트 컴파일러가 사전에 타입 에러를 발견하여 에러를 방지할 수 있습니다. 또한 명확하게 지정한 타입을 제외한 다른 타입은 허용하지 않아 컴포넌트를 안전하게 조합하고 사용하여, 결과적으로 프로젝트를 더 안정적으로 운영할 수 있습니다.
 

12.3 컴포넌트와 props

12.3.1 리액트 컴포넌트의 props

props는 리액트 애플리케이션에서 컴포넌트 간에 값을 전달하는 수단으로 사용됩니다. 주로 부모 컴포넌트로부터 정보를 받아와 자식 컴포넌트에 전달됩니다. 순수 함수를 기반으로 하는 리액트 컴포넌트는 항상 동일한 입력값에 대한 동일한 결과를 반환해야 하므로 props 값은 readonly이며, 컴포넌트 내부에서 변경할 수 없습니다.
💡
state와 props의 차이 props("properties"의 줄임말)와 state는 모두 순수 자바스크립트 객체입니다. 둘 다 렌더링 결과에 영향을 미치는 정보를 담고 있지만, props는 컴포넌트에 전달되는 반면(함수 매개변수와 유사), state는 컴포넌트 내에서 관리된다는 점에서 한 가지 중요한 차이가 있습니다(함수 내에서 선언된 변수와 유사). - 출처: https://legacy.reactjs.org/docs/faq-state.html
 
💡
컴포넌트(Component)란?
컴포넌트는 현대 웹 개발에서 중요한 개념 중 하나입니다. 이 개념을 통해서 개발의 효율성과 애플리케이션의 품질을 크게 향상했다고 볼 수 있습니다. 자바스크립트, 특히 웹 개발에서 컴포넌트(component)는 재사용이 가능한 코드 조각을 의미합니다. 이는 웹 페이지나 애플리케이션을 구성하는 독립적이고 재사용이 가능한 부분으로, 사용자 인터페이스(UI)의 한 부분을 형성합니다. 컴포넌트 기반 개발은 코드의 재사용성을 높이고, 유지 보수를 용이하게 하며, 대규모 애플리케이션의 개발을 체계적으로 할 수 있도록 돕습니다. 예를 들어, 웹 애플리케이션에서 버튼, 입력, 다이얼로그 박스 등은 모두 컴포넌트로 개발될 수 있습니다. 각 컴포넌트는 자체적인 HTML, CSS, 그리고 자바스크립트 코드를 가지고 있어, 이를 조합하여 복잡한 사용자 인터페이스를 구축할 수 있습니다.
자바스크립트에서는 여러 프레임워크와 라이브러리를 통해 컴포넌트 기반 개발을 지원합니다. 대표적으로 React.js, Vue.js, Angular 등이 있으며, 각각의 특성에 맞게 컴포넌트를 정의하고 관리합니다. 예를 들어, React에서는 JSX를 사용하여 컴포넌트를 정의하고, 상태 관리와 생명주기 메서드를 통해 동적인 데이터 처리와 UI 업데이트를 수행합니다.
 

12.3.2 리액트 컴포넌트 타입

리액트는 다양한 컴포넌트 형태에 맞춰 타입을 지정할 수 있도록 리액트 내장 타입을 제공하고 있습니다. 특히 클래스 형 컴포넌트를 사용하던 리액트는 훅을 도입하면서 함수형 컴포넌트를 권장하며 적극적으로 사용하게 됐습니다. 리액트 컴포넌트에 타입을 지정하는 방법에는 4 가지가 있습니다.
interface MyProps { id: number } // 클래스형 컴포넌트 class MyComponent extends React.Component<MyProps> {} // 함수형 컴포넌트 - 함수 선언 방식 function MyComponent(props: MyProps): JSX.Element {} // 함수형 컴포넌트 - 함수 표현식 const MyComponent = (props: MyProps): JSX.Element => {}; // 함수형 컴포넌트 - 함수 표현식 const MyComponent: React.FC<MyProps> = (props) => {};
컴포넌트 타입 지정 방식 중 리액트에서 권장하는 함수형 컴포넌트의 타이핑을 중점적으로 알아보겠습니다. 함수형 컴포넌트의 타입 지정에 사용되는 타입에는 JSX.Element 타입과 React.FC 타입이 있습니다.
 

1) JSX.Element 타입

JSX.Element 타입은 리액트의 요소를 나타내는 타입입니다. 이 밖에도 ReactNode, ReactElement 타입이 존재합니다.
interface Element extends React.ReactElement<any, any> {}
리액트 요소를 나타내는 3 가지 타입의 관계는 아래와 같이 표현할 수 있습니다.
리액트 요소를 나타내는 타입들의 관계리액트 요소를 나타내는 타입들의 관계
리액트 요소를 나타내는 타입들의 관계
JSX.Element 타입 정의를 보면 props와 타입 필드를 any 타입으로 가지는 ReactElement 타입임을 알 수 있다. ReactElement 타입은 리액트의 JSX를 기반으로 생성된 가상 DOM 요소(Element), 즉 리액트 엘리먼트를 나타내는 타입입니다.
💡
리액트의 JSX와 가상 DOM 가상 DOM은 리액트의 핵심적인 개념으로 실제 DOM의 가벼운 복사본이며 리액트가 UI의 변화를 효율적으로 처리하기 위해 사용됩니다. 리액트는 실제 DOM을 직접 조작하는 것이 아니라 컴포넌트의 상태가 변경될 때마다 가상 DOM에 렌더링을 수행합니다. 그리고 이전 가상 DOM과 비교하여 실제 변경된 부분만 실제 DOM에 반영함으로써, 리액트는 데이터의 변화를 UI에 빠르게 반영될 수 있도록 합니다. JSX(JavaScript XML)는 리액트에서 리액트 엘리먼트를 생성하기 위해 사용되는 자바스크립트의 확장 문법입니다. JSX를 사용하면 React에서 HTML를 작성할 수 있습니다. JSX를 통해 컴포넌트의 구조를 명확하게 표현할 수 있으며, HTML과 유사한 문법으로 제공하여 자바스크립트와 함께 HTML을 더 쉽게 작성하고 추가할 수 있는 장점이 있습니다. 예를 들면, JSX에서 사용된 <div> 태그는 실제로 React.createElement(’div’) 함수 호출로 변환됩니다.
 

2) React.FC 타입

props와 반환 타입을 명시하는 방식보다 간결한 제네릭 방식을 사용하는 React.FC(Function Component) 타입은 함수형 컴포넌트 타입을 지정합니다. 리액트 18 버전 이전에는 React.FC와 유사한 React.VFC(Void Function Component)도 있었지만 더 이상 사용되지 않습니다. React.FC 타입 정의는 아래와 같습니다. React.FC 타입은 FunctionComponent 타입을 정의되며, 이 타입은 다시 빈 객체({})를 초깃값으로 갖는 props를 받아 ReactElement와 원시 자료형들을 포함한 ReactNode 타입을 반환합니다.
type FC<P = {}> = FunctionComponent<P>; interface FunctionComponent<P = {}> { (props: P, context?: any): ReactNode; propTypes?: WeakValidationMap<P> | undefined; defaultProps?: Partial<P> | undefined; displayName?: string | undefined; } type ReactNode = | ReactElement | string | number | Iterable<ReactNode> | ReactPortal | boolean | null | undefined | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[ keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES ];
 

3) 프로젝트 컴포넌트에 타입 지정

이제 각각의 컴포넌트에 타입을 지정해 봅시다. React.FC 타입을 사용하는 경우, 변수명에 어노테이션으로 타입을 써주고 제네릭의 타입 변수로 컴포넌트가 전달받을 prop의 타입을 써주면 됩니다. 함수 선언 방식으로 작성된 App , Todo, TodoList 컴포넌트는 타입스크립트 컴파일러에 의해 타입이 자동으로 추론되어 반환 타입인 JSX.Eleement은 생략했습니다.
타입 추론된 App 컴포넌트의 JSX.Element 타입타입 추론된 App 컴포넌트의 JSX.Element 타입
타입 추론된 App 컴포넌트의 JSX.Element 타입
// App.tsx function App() { // 리턴 타입 생략 (타입 추론: JSX.Element) // ... } // Todo.tsx export default function Todo() { // 리턴 타입 생략 (타입 추론: JSX.Element) // ... } // AddTodo.tsx const AddTodo: React.FC<AddTodoProps> = ({ todoList, setTodoList, idCount, setIdCount }) => { // ... }; // TodoList.tsx export default function TodoList({ todoList, setTodoList, }: TodoListProps) { // 리턴 타입 생략 (타입 추론: JSX.Element) // ... } // ToggleTodo.tsx const ToggleTodo: React.FC<ToggleTodoProps> = ({ id, todoList, setTodoList }) => { // ... }; // DeleteTodo.tsx const DeleteTodo: React.FC<DeleteTodoProps> = ({ id, done, setTodoList }) => { // ... };
 

12.3.3 props의 타입 지정

앞서 정리한 것처럼 props는 컴포넌트 간에 값을 전달 수단이며 타입을 지정할 수 있습니다. props의 타입 지정을 통해서 각 프로퍼티의 타입과 함께 어떤 역할을 하는지 구체적인 정보를 파악할 수 있습니다. 컴파일에 전달한 props 인터페이스를 정의해서 기존 코드에 적용해 보도록 해봅시다.
프로젝트 컴포넌트 구조와 props프로젝트 컴포넌트 구조와 props
프로젝트 컴포넌트 구조와 props

1) AddTodo 컴포넌트 props

Todo 컴포넌트가 반환하는 리액트 엘리먼트를 보면 하위 컴포넌트와 전달하는 props을 알 수 있습니다. 하위 컴포넌트로는 AddTodo와 TodoList 컴포넌트가 있습니다. 전달하는 props는 앞서 타입을 지정한 useState의 상태와 setState 함수로 구성되어 있습니다.
// Todo.tsx return ( <Container> <Wrapper> <Title>My Todolist</Title> <AddTodo todoList={todoList} setTodoList={setTodoList} idCount={idCount} setIdCount={setIdCount} /> <TodoList todoList={todoList} setTodoList={setTodoList} /> </Wrapper> </Container> );
Todo 컴포넌트에서 useState 훅의 타입을 어떻게 정리했는지 다시 확인해 보겠습니다.
interface TodoItem { id: number; content: string; done: boolean; } export default function Todo() { const [idCount, setIdCount] = useState<number>(3); const [todoList, setTodoList] = useState<TodoItem[]>([ // ... ]); // ... }
앞서 props를 전달하는 Todo 컴포넌트에서 타입을 지정해 두었기 때문에 props을 전달받는 AddTodoTodoList 컴포넌트에서 props 인터페이스 타이핑은 어렵지 않게 작성할 수 있습니다. AddTodo 컴포넌트는 구조 분해 할당을 통해 전달받은 AddTodoProps 타입의 props 객체로부터 todoList, setTodoList, idCount, setIdCount만을 받습니다.
// AddTodo.tsx interface AddTodoProps { todoList: { id: number; content: string; done: boolean }[]; setTodoList: React.Dispatch<React.SetStateAction<{ id: number; content: string; done: boolean }[]>>; idCount: number; setIdCount: React.Dispatch<React.SetStateAction<number>>; } const AddTodo: React.FC<AddTodoProps> = ({ todoList, setTodoList, idCount, setIdCount }) => { // ... };
 

2) TodoList 컴포넌트 props

TodoList 컴포넌트는 Todo 컴포넌트로부터 todoListsetTodoList를 프로퍼티로 가지는 props를 전달받습니다. props 인터페이스 TodoListProps를 정의해 주고 TodoList 컴포넌트에 적용해 줍니다.
// TodoList.tsx interface Todo { id: number; content: string; done: boolean; } interface TodoListProps { todoList: Todo[]; setTodoList: React.Dispatch<React.SetStateAction<Todo[]>>; } export default function TodoList({ todoList, setTodoList }: TodoListProps) { // ... }
TodoList 컴포넌트는 다시 하위의 ToggleTodo와 DeleteTodo 컴포넌트에 props를 전달합니다.
// TodoList.tsx return ( <> {todoList.map((todo: Todo) => { return ( <Wrapper key={todo.id}> <Item> <ToggleTodo id={todo.id} todoList={todoList} setTodoList={setTodoList} /> <Content checked={todo.done}>{todo.content}</Content> <DeleteTodo id={todo.id} done={todo.done} setTodoList={setTodoList} /> </Item> </Wrapper> ); })} </> ); }
 

3) ToggleTodo와 DeleteTodo 컴포넌트 props

앞서 타이핑한 방식과 동일하게 전달받는 props의 인터페이스를 정의하고 각각의 컴포넌트에 타입을 지정해 줍니다.
// ToggleTodo.tsx interface ToggleTodoProps { id: number; todoList: { id: number; content: string; done: boolean }[]; setTodoList: React.Dispatch< React.SetStateAction<{ id: number; content: string; done: boolean }[]> >; } const ToggleTodo: React.FC<ToggleTodoProps> = ({ id, todoList, setTodoList }) => { // ... } // DeleteTodo.tsx interface DeleteTodoProps { id: number; done: boolean; setTodoList: React.Dispatch< React.SetStateAction<{ id: number; content: string; done: boolean }[]> >; } const DeleteTodo: React.FC<DeleteTodoProps> = ({ id, done, setTodoList }) => { // ... };
투두 리스트 프로젝트에 존재하는 모든 컴포넌트의 상태와 컴포넌트 그리고 props의 타입을 명시적으로 지정해 봤습니다. 타입스크립트를 프로젝트에 적용하면 컴포넌트가 전달하거나 전달받는 정확한 타입을 통해 잘못된 props을 전달하는 실수를 미연에 방지하고, 전달되는 props의 프로퍼티가 어떤 역할을 하는지 파악할 수 있습니다. 또한 컴포넌트 지역 상태 관리나 내부 동작을 구현할 때도 타입을 명확하게 지정함으로써 의도치 않은 오류를 컴파일 타임에 확인할 수 있습니다.
 

12.4 이벤트

타입스크립트에서 이벤트 타입을 정의하는 것은 코드의 안정성을 높일 수 있습니다. 올바른 이벤트 타입을 사용함으로써 해당 이벤트 객체가 제공하는 프로퍼티와 메서드에 대한 정확한 자동완성과 타입 체킹을 받을 수 있기 때문입니다.
// AddTodo.tsx const AddTodo: React.FC<AddTodoProps> = ({ todoList, setTodoList, idCount, setIdCount }) => { const [inputValue, setInputValue] = useState(''); const onChangeInput = (event: React.ChangeEvent<HTMLInputElement>) => { setInputValue(event.target.value); }; const onAddTodo = () => { setTodoList([...todoList, { id: idCount, content: inputValue, done: false }]); setIdCount((prevState) => prevState + 1); setInputValue(''); }; return ( <Wrapper> <Input type='text' value={inputValue} onChange={onChangeInput} /> <Button disabled={!inputValue} $value={inputValue} onClick={onAddTodo}> 추가 </Button> </Wrapper> ); };
event가 어떤 타입인지 확인하는 방법 중 하나는, 코드 에디터에서 해당 변수 위에 마우스 커서를 올려두는 것입니다. event 위에 마우스를 올리면, 에디터는 event의 타입을 React.ChangeEvent<HTMLInputElement>로 보여줄 것입니다. 이 타입은 React에서 제공되며, HTML <input> 요소의 변경 이벤트를 나타냅니다. onChange 이벤트는 사용자가 입력 필드에 입력할 때마다 발생하며, 사용자가 입력 필드에 타이핑을 할 때마다 setInputValue 함수를 호출하여 inputValue 상태를 업데이트합니다. 이벤트 핸들러에 전달되는 이벤트 객체의 타입을 정의하고, event.target.value를 통해 현재 입력 필드의 값을 얻을 수 있습니다.
 

12.5 Styled-Components

12.5.1 설치 방법

npm install styled-components npm install @types/styled-components --save-dev

12.5.2 사용 방법

기본적인 스타일드 컴포넌트 사용법은 자바스크립트와 마찬가지로 타입스크립트에서도 동일합니다.
// ToggleTodo.tsx const Input = styled.input` cursor: pointer; `;
// Todo.tsx const Container = styled.main` display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 100vw; min-height: 100vh; background-color: #f6f7f9; `; const Title = styled.h1` color: #7990ca; `;
만약 동적으로 스타일을 적용하고 싶다면 props와 함께 사용할 수 있습니다.

1) AddTodo 예시

// AddTodo.tsx <Button disabled={!inputValue} $value={inputValue} onClick={onAddTodo}> 추가 </Button>;
// AddTodo.tsx const Button = styled.button < { $value: string } > ` padding: 3px 15px; border: none; background-color: ${({ $value }) => ($value ? "#7990CA" : "#939eb6")}; color: #f6f7f9; cursor: pointer; border-radius: 3px; `;
AddTodo.tsx 파일에서는 Button 컴포넌트를 사용하여 '추가' 버튼을 만들고 있습니다. 이 버튼은 사용자가 입력 필드에 어떠한 값을 입력했는지 여부에 따라 활성화 상태가 결정됩니다. 만약 사용자가 아무런 값을 입력하지 않았다면 버튼은 비활성화됩니다. 사용자가 입력한 값을 value라는 prop으로 Button 컴포넌트에 전달하며, 이 버튼을 클릭하면 onAddTodo 함수가 실행됩니다. Button 컴포넌트의 스타일은 styled-components 라이브러리를 사용해 정의되어 있습니다. 이 스타일 정의에는 타입스크립트의 제네릭 문법을 활용하여 value prop의 타입을 문자열로 지정합니다. 이를 통해 Button 컴포넌트는 value prop을 필수로 받아야 하며, 이 값은 반드시 문자열이어야 합니다. value prop의 값에 따라 버튼의 배경색이 동적으로 변합니다. 만약 value가 비어있으면(사용자가 아무런 입력도 하지 않았다면) 배경색은 #939eb6으로 설정됩니다. 반면에 value에 어떤 값이 존재한다면, 배경색은 #7990CA로 변경되어 사용자가 입력 필드에 값을 입력했는지 쉽게 구분할 수 있습니다.

2) DeleteTodo 예시

// DeleteTodo.tsx <Button $isActive={done} onClick={() => onDeleteTodo()}> 삭제 </Button>;
// DeleteTodo.tsx interface ButtonProps { $isActive: boolean; } const Button = styled.button < ButtonProps > ` padding: 3px 15px; border: none; background-color: ${(props) => (props.$isActive ? "#7990ca" : "#939eb6")}; color: #f6f7f9; border-radius: 3px; cursor: pointer; &:hover { background-color: #7990ca; } `;
ButtonProps 인터페이스를 통해 Button 컴포넌트의 props 타입을 정의합니다. 여기서 isActive prop만을 가지며, boolean 타입입니다. Button 컴포넌트의 스타일은 isActive prop에 따라 동적으로 변경됩니다. 할 일이 완료(isActive가 true) 된 경우 배경색은 #7990ca로, 그렇지 않은 경우(isActive가 false)는 #939eb6로 설정됩니다.

3) TodoList 예시

// TodoList.tsx <Content checked={todo.done}> {todo.content} </Content>
// TodoList.tsx const Content = styled.p<{ checked: boolean }>` text-decoration: ${({ checked }) => checked && "line-through"}; `;
할 일의 완료 여부는checked prop으로 전달되며, 이는 todo.done의 값에 의해 결정됩니다. Content 컴포넌트는 <p> 태그를 기반으로 하며, 제네릭 타입을 사용하여 checked라는 boolean 타입의 prop을 받습니다. 할 일이 완료되었을 때 (checkedtrue일 때), text-decoration 속성을 line-through로 설정함으로써 할 일이 완료된 것을 시각적으로 나타냅니다.
 

12.6 Context API

12.6.1 TodoContext 생성

// TodoContext.tsx import React, { createContext, useContext, useState, ReactNode } from "react"; interface Todo { id: number; content: string; done: boolean; } interface TodoContextType { todoList: Todo[]; onToggleTodo: (id: number) => void; onAddTodo: (content: string) => void; onDeleteTodo: (id: number) => void; } interface TodoProviderProps { children: ReactNode; } const TodoContext = createContext<TodoContextType | null>(null); export const useTodos = () => { const context = useContext(TodoContext); if (!context) throw new Error("UseTodos는 TodoProvider 내에서 사용해야 합니다."); return context; }; export const TodoProvider: React.FC<TodoProviderProps> = ({ children }) => { const [todoList, setTodoList] = useState<Todo[]>([ { id: 0, content: "치킨 먹기", done: false }, { id: 1, content: "피자 먹기", done: false }, { id: 2, content: "마라탕 먹기", done: false }, ]); const onToggleTodo = (id: number) => { setTodoList( todoList.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo ) ); }; const onAddTodo = (content: string) => { const newId = todoList.length > 0 ? Math.max(...todoList.map((todo) => todo.id)) + 1 : 0; setTodoList([...todoList, { id: newId, content, done: false }]); }; const onDeleteTodo = (id: number) => { setTodoList(todoList.filter((todo) => todo.id !== id)); }; return ( <TodoContext.Provider value={{ todoList, onToggleTodo, onAddTodo, onDeleteTodo }} > {children} </TodoContext.Provider> ); };
기존 Todo 컴포넌트에서 useState로 생성되어 하위 컴포넌트로 전달되던 todoList 객체와 나머지 컴포넌트에서 선언하고 사용되던 AddTodo, DeleteTogo, ToggleTodo 함수를 리액트의 상태 관리 hook인 contextAPI를 활용하여 전역으로 관리하고 각 컴포넌트에 전달되는 props 사용을 최소화하여 props drilling을 방지할 수 있습니다.

1) createContext

useState와 같이 인터페이스 또는 타입 별칭을 생성하여 제네릭 형태로 타입을 지정해 줍니다. 선언된 해당 context는 값이 사용되기 전이기 때문에 유니온 타입을 통해 초기화된 값 null이 포함됩니다.

2) provider

TodoProvider에서 todoList 객체를 전역으로 공유하기 위해서 context의 provider에 타입을 지정해 주어야 합니다. 이를 위해 사용되는 것이 <TodoProviderProps> 제네릭 타입입니다. 이 타입은 provider에서 children으로 받는 props들의 타입을 ReactNode로 지정해 주어야 하는데 이는 리액트에서 사용되는 함수 및 hook의 모든 타입을 포함합니다.

12.6.2 provider 설정

// App.tsx import React from 'react'; import { TodoProvider } from './TodoContext'; import Todo from './Todo'; function App() { return ( <TodoProvider> <Todo /> </TodoProvider> ); } export default App;
App 컴포넌트에서 provider의 store로 공유되는 todoList 상태와 Add, Delete, Toggle 함수를 사용하기 위해 TodoProvider 컴포넌트를 TodoContext를 통해 import 받고 하위 컴포넌트로 Todo 컴포넌트를 설정합니다. 여기서 TodoProvider에 타입을 추가로 명시하지 않는 이유는 TodoContext에서 TodoProviderProps 제네릭 타입을 통해 이미 명시가 동적으로 타입을 제공받기 때문입니다.

12.6.3 전역 상태 공유

1) Todo 컴포넌트

// Todo.tsx import React from 'react'; import styled from 'styled-components'; import TodoList from './TodoList'; import AddTodo from './AddTodo'; export default function Todo() { return ( <Container> <Wrapper> <Title>My Todolist</Title> <AddTodo /> <TodoList /> </Wrapper> </Container> ); } const Wrapper = styled.div``; const Container = styled.main` display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 100vw; min-height: 100vh; background-color: #f6f7f9; `; const Title = styled.h1` color: #7990ca; `;
todoList 상태는 TodoContext에서 useContext로 선언되었기 때문에 Todo 컴포넌트에서 useState로 선언하고 하위 컴포넌트에서 props로 할당해 줄 필요가 없어진 것을 확인할 수 있습니다.
 

2) TodoContext 전역 상태를 공유 받는 나머지 컴포넌트

// TodoList.tsx import React from "react"; import styled from "styled-components"; import { useTodos } from "./TodoContext"; import ToggleTodo from "./ToggleTodo"; import DeleteTodo from "./DeleteTodo"; export default function TodoList() { const { todoList } = useTodos(); return ( <> {todoList.map((todo) => ( <Wrapper key={todo.id}> <Item> <ToggleTodo id={todo.id} /> <Content checked={todo.done}>{todo.content}</Content> <DeleteTodo id={todo.id} /> </Item> </Wrapper> ))} </> ); } const Wrapper = styled.div` display: flex; font-size: 30px; `; const Content = styled.p<{ checked: boolean }>` text-decoration: ${({ checked }) => checked && "line-through"}; `; const Item = styled.div` display: flex; align-items: center; justify-content: space-between; width: 100%; color: #7990ca; margin-right: auto; `;
// AddTodo.tsx import React, { useState } from "react"; import styled from "styled-components"; import { useTodos } from "./TodoContext"; export default function AddTodo() { const { onAddTodo } = useTodos(); const [inputValue, setInputValue] = useState(""); const onChangeInput = (event: React.ChangeEvent<HTMLInputElement>) => { setInputValue(event.target.value); }; const onAdd = () => { if (!inputValue) return; onAddTodo(inputValue); setInputValue(""); }; return ( <Wrapper> <Input value={inputValue} onChange={onChangeInput} /> <Button disabled={!inputValue} $value={inputValue} onClick={onAdd}> 추가 </Button> </Wrapper> ); } const Wrapper = styled.div` display: flex; align-items: center; gap: 5px; padding-bottom: 20px; `; const Input = styled.input` padding: 0 15px; border: none; background-color: transparent; border-bottom: 1px solid #b5c3df; color: #7990ca; outline: none; &:focus { border-width: 2px; } `; const Button = styled.button<{ $value: string }>` padding: 3px 15px; border: none; background-color: ${({ $value }) => ($value ? "#7990CA" : "#939eb6")}; color: #f6f7f9; cursor: pointer; border-radius: 3px; `;
// DeleteTodo.tsx import React from "react"; import styled from "styled-components"; import { useTodos } from "./TodoContext"; interface ButtonProps { $isActive: boolean; } interface DeleteTodoProps { id: number; } export default function DeleteTodo({ id }: DeleteTodoProps) { const { onDeleteTodo, todoList } = useTodos(); const todo = todoList.find((todo) => todo.id === id); return ( <Button $isActive={todo ? todo.done : false} onClick={() => onDeleteTodo(id)} > 삭제 </Button> ); } const Button = styled.button<ButtonProps>` padding: 3px 15px; border: none; background-color: ${(props) => (props.$isActive ? "#7990ca" : "#939eb6")}; color: #f6f7f9; border-radius: 3px; cursor: pointer; &:hover { background-color: #7990ca; } `;
// ToggleTodo.tsx import React from "react"; import styled from "styled-components"; import { useTodos } from "./TodoContext"; interface ToggleTodoProps { id: number; } export default function ToggleTodo({ id }: ToggleTodoProps) { const { onToggleTodo } = useTodos(); return <Input type="checkbox" onChange={() => onToggleTodo(id)} />; } const Input = styled.input` cursor: pointer; `;
나머지 컴포넌트에서는 todoList의 상태와 Add, Delete, Toggle 함수가 있는 TodoContext를 props 및 컴포넌트에서 선언하는 대신, useTodos hook 을 사용하여 공유 받아 코드를 최적화하고 안정성을 확보할 수 있게 합니다. 각각의 컴포넌트에 provider를 통해 공유 받는 context의 todoList 상태와 함수에 타입을 정의하지 않는 이유는 TodoContext에서 인터페이스로 선언된 TodoContextType로 context 전역 상태에 타입을 정의해 주었기 때문입니다. 이로 인하여 나머지 컴포넌트들에서 구조 분해 할당을 통해 제공받는 provider의 props들의 타입이 자동으로 추론됩니다.