본문 바로가기
React 완벽 가이드

React+Typescript

by ash9river 2024. 8. 23.

Typescript

타입스크립트

  • 타입스크립트는 자바스크립트의 superset 언어이다.
    • 자바스크립트를 기반으로 하나, 자바스크립트보다 더 확장된 프로그래밍 언어이다.
  • 타입스크립트는 리액트와는 다르게 자바스크립트 라이브러리가 아니다.
    • 이로 하여금 자바스크립트의 기존 기능을 기반으로 새로운 기능을 만들거나, 기존 기능을 확장하지 않는다.
    • 대신에 자바스크립트의 주요 문법보다 확장된 문법을 가진다.
  • 타입스크립트는 정적 타입(statically Typed)의 특징을 갖는다.
    • 자바스크립트는 동적 타입(dynamically Typed) 언어임을 생각하면 대조적이다.

원시 타입(Primitives Type)

  • 자바스크립트의 기본형 데이터는 숫자형, 문자열, 불리언(boolean)형, null, undefined이 있다.
let age: number;

age = 18;

let myAge: number = 26;

let userName: string = 'ash9river';

let isInstructor: boolean = true;
let isNull: null = null;

let isUndefined: undefined = undefined;

배열 및 객체 타입

  • 배열에는 단순히 대괄호[]를 추가.
  • 객체는 지정을 안할 시에는 원시 타입으로 any가 지정되나, 자바스크립트랑 다를 바가 없어서 좋지 않은 방법이다.
  • 또한, 객체에 타입 지정을 하지 않은 필드는 저장할 수 없다.
// arrays

let hobbies: string[];

hobbies = ['Sports', 'Cooking', 'Games'];

// bad

let person;

person = {
  name: 'ash9river',
  age: 26,
};

// good

let man: {
  name: string;
  age: number;
};

man = {
  name: 'ash9river',
  age: 26,
};

// object array

let people: {
  name: string;
  age: number;
}[];

타입 추론(Type Inference)

  • 타입스크립트는 가능한 많은 타입을 유추할려고 한다.
  • 명시적인 타입 표기가 없어도 타입 추론을 통해 타입을 지정한다.
  • 타입 추론을 이용해서 코드를 작성하는게 권장된다.(형식 지정에 따른 불필요한 코드를 줄이기 위함)
let course = 'React - The Complete Guide';

course = 1234; // error

유니온 타입(Union Type)

  • 하나의 변수에 두 개 이상의 타입을 지정할 수 있게 해주는 기능으로, 유니온 타입 기능이라 한다.
let course: string | number = 'React - The Complete Guide';

course = 1234;

타입 별칭(Type Aliases)

  • 동일한 코드 중복을 피하기 위해서 기본 타입을 만들어서 복잡한 타입을 정의하고, 그 타입 별칭을 사용함으로써, 반복해서 타입을 정의하는 것을 피할 수 있다.
  • type 키워드를 이용하여 타입을 만들 수 있다.
type Person = {
  name: string;
  age: number;
};

let personWithType: Person = {
  name: 'ash9river',
  age: 26,
};

let peopleWithType: Person[];

함수 및 함수 타입

  • 함수를 사용할 때, 타입을 지정하는 위치가 따로 있다.
  • 이 이미지에서는 타입 추론을 통해 함수에 타입이 지정되었다.
// Function with Type Inference
function add(a: number, b: number) {
  return a + b;
}

// Function without Type Inference
function add(a: number, b: number): number {
  return a + b;
}
  • 타입스크립트가 타입을 추론하기 때문에 함수에 명시적으로 타입을 지정할 필요는 없다.

  • 그렇지만 함수에서 타입을 사용할 때, 매개변수의 타입뿐만이 아니라 반환값의 타입도 생각은 하는 것이 필요하다.

  • 만약 반환값이 없는 함수가 있다면, 그 함수는 void를 타입으로 갖는다.

    • 이 함수의 반환 값을 받아서 작업하려면 undefined 타입으로 값을 받아야 한다.
  • void는 함수에만 있는 특수한 타입으로 반환값이 없는 함수의 반환 타입으로 사용된다.

제네릭(Generic)

  • 제네릭은 타입스크립트에서 함수, 클래스, 인터페이스 등을 정의할 때 타입을 파라미터화하는 기능이다.
  • 이를 통해 함수나 클래스를 사용할 때 원하는 타입을 동적으로 지정할 수 있다.
  • 다음과 같은 코드가 있다고 생각해보자.
function insertAtBeginning(array: any[], value: any) {
  const newArray = [value, ...array];

  return newArray;
}

const demoArray = [1, 2, 3];

const updatedArray = insertAtBeginning(demoArray, -1);
  • 이 코드의 문제점은 updatedArray에 추론된 배열의 타입이 any라는 것이다.
    • 타입스크립트에서는 이 배열에 number만 들어있다는 것을 인식하지 못하였기 때문에, 타입스크립트는 이 배열을 제대로 지원할 수 없다.
      • 함수의 반환형이 any이어서 어떤 타입이든 받을 수 있지만, 실제로 함수가 반환할 때 어떤 타입인지에 대한 정보는 잃게 된다.
  • 제네릭 타입을 정의함으로써, 해결할 수 있다.
  • 함수의 이름과 매개변수 사이에 <>를 추가하고, 식별자로 TypeT를 따서 사용한다.(식별자는 다르게 지정해도 된다.)
    • 이를 generic type placeholder라고 한다.
function insertAtBeginning<T>(array: T[], value: T) {
  const newArray = [value, ...array];

  return newArray;
}

const demoArray = [1, 2, 3];

const updatedArray = insertAtBeginning(demoArray, -1);
  • 결과적으로 제네릭 타입을 통해 any 타입이 아니라 number[] 타입임을 제대로 추론할 수 있게 된다.
  • 자유롭게 어떤 타입이든 사용할 수 있지만, 특정 타입을 이용해 해당 함수를 실행하면, 그 해당 특정 타입으로 고정되어서 동작한다.

❗ 함수 작성을 할 때, 제네릭 타입을 사용하면 유연성과 타입 안정성을 높여준다.

제네릭 자세한 설명

  • 다음과 같은 코드가 있다고 생각해보자.
let thisIsNumbers: number[] = [1, 2, 3];
  • thisIsNumbers의 타입은 number[]이다.
  • number[]은 타입스크립트의 표기법으로써 숫자의 배열이라 정의된다.
  • 그러나, 이 nubmer[] 표기법은 Syntax Sugar이다.
    • Syntax Sugar란 여기서 코드를 읽는 사람 또는 작성하는 사람을 위해 편하게 디자인 된 문법이라는 뜻이다.
  • number[]의 실제 타입은 Array이다.
    • 모든 배열은 Array 타입이다.
  • 그러므도 상기 코드도 하단의 방식으로 작성할 수 있다.
let thisIsNumbers: Array<number> = [1, 2, 3];
  • 이 코드를 통해, generic type placeholder에서 <T>가 작성자가 스스로 자신만의 타입을 만드는 것이 아니라 타입스크립트에게 실제 타입이 해당 유형을 대표한다는 것을 알 수 있다.

컴포넌트 with Typescript

  • 리액트에서 props는 언제나 객체 형태이다.
  • 그런데, 타입스크립트에서 props에 대해 정의를 할려면 객체 쌍만 정의를 해야 하는게 아니라, children까지 타입 정의를 해야 한다.
  • 이는 번거롭고 비생산적인 결과를 초래한다.
function Todos(props: { items: string[]; children: any }) {
  return (
    <ul>
      <li>Learn React</li>
      <li>Learn Typescript</li>
    </ul>
  );
}
  • 리액트와 타입스크립트는 이에 대한 해결책으로 제네릭 타입을 지원한다.
    • 함수형 컴포넌트를 바로 제네릭 함수로 변환해서 이용하는 방식이다.
  • 함수형 컴포넌트에서 설정을 추가하여, 리액트 함수형 컴포넌트로 동작하도록 만들어서 children과 같은 기본 props를 사용할 수 있도록 만든다.
  • 그 다음, 새로운 props를 추가로 정의한다.
    • 이 때, 정의되는 새로운 속성은 props 객체에 합쳐져야 한다.
  • React.FC를 타입으로 지정하는 방법이다.(권장되지 않는다.
  • props 에 대한 타입을 선언 할 때에는 interface 또는 type 을 사용하면 되고, 프로젝트 내부에서 일관성만 지키면 된다.
const Todos: React.FC = () => {
  return (
    <ul>
      <li>Learn React</li>
      <li>Learn Typescript</li>
    </ul>
  );
};

export default Todos;

다양한 props 전달 방식

  1. 함수 시그니처 직접 지정
import { ReactElement } from 'react';

function Todos(props: { items: string[]; children: ReactElement }) {
  const { items, children } = props;
  return <ul>{children}</ul>;
}

export default Todos;
  1. Type Aliases
import { ReactElement } from 'react';

type TodosProps = {
  items: string[];
  children: ReactElement;
};

function Todos(props: TodosProps) {
  const { items, children } = props;
  return <ul>{children}</ul>;
}

export default Todos;
  1. Interface
import { ReactElement } from 'react';

interface TodosProps {
  items: string[];
  children: ReactElement;
}

function Todos(props: TodosProps) {
  const { items, children } = props;
  return <ul>{children}</ul>;
}

export default Todos;
  • interfacetype 중 어떤 것을 사용할지는 개발자의 취향이나 프로젝트의 요구에 따라 다를 수 있다.
  • 일반적으로 typeunion 타입, intersection 타입 등을 더 쉽게 정의할 수 있다.
  • 반면에, interface는 확장이 가능하며, 클래스나 객체의 구조를 정의하는 데에 더 많이 사용된다.

form with typescript

  • typesubmit이면 button의 기존 타입을 생략해도 됐던 기존과는 다르게, 명시적으로 나타내야 한다.
  • 또한 event를 다룰 때에는, event가 무엇인지 지정해야 하는데, 여기선 React.FormEvent라 지정하였다.
    • 이를 통해 event.preventDefault()가 자동완성이 뜬다.

function NewTodo() {

  function submitHandler(event: React.FormEvent) {
    event.preventDefault();
  }

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">
        Todo text
        <input type="text" id="text" />
      </label>
      <button type="submit">Add Todo</button>
    </form>
  );
}

export default NewTodo;

useRef with typescript

  • 타입스크립트에서는 useRef를 사용할 때, ref에 저장될 데이터가 어떤 타입인지 정확히 밝혀야 한다.
    • 이를 위해 제네릭 타입을 사용한다.
  • 이는 useRef 자체가 제네릭 타입으로 정의되어 있는 이유이다.
  • useRefref에 저장되는 모든 종류의 데이터에 사용될 수 있고, ref와 연결할 수 있는 모든 HTML 요소에 사용될 수 있다.
  • 그런데 폼 제출 시에는 input 요소를 저장할 것이기 때문에 HTMLInputElement 타입을 가진다.
    • 참고로, button 요소의 타입은 HTMLButtonElement, p 요소의 타입은 HTMLParagraphEkenebt, 이런 식이다.
  • 그리고, 참조에 기본값을 직접 설정해야 한다.
const enteredText = todoTextInputRef.current?.value;
  • currentvalue에 접근하는데 자동완성으로 ?가 작성되고, 이를 제거하면 오류가 나온다.
  • 이는 타입스크립트가 ref에 값이 설정되어있지 않을 수도 있어서 그런 것인데, 폼 제출시 값이 무조건 있기 때문에 null이 될 수 없다.
  • 따라서, ? 대신에 !을 사용해서, 이 값이 null이 될 수는 있지만, 이 시점에서는 절대 null이 될 수 없다고 알린다.
    • !는 절대 null이 아닐 경우에만 사용한다.
const enteredText = todoTextInputRef.current!.value;

props로 함수 전달

  • props로 함수를 전달하려면 화살표 함수를 사용하면 간단하다.
  • 단순히 함수명에 마우스를 hover하면 안내해준다.
// 상위 컴포넌트
  function onAddTodo(todoText: string) {}

  return (
    <>
      <NewTodo onAddTodo={(text) => onAddTodo(text)} />
      <Todos items={todo} />
    </>
  );
// 하위 컴포넌트
import { useRef } from 'react';

type NewTodoProps = {
  onAddTodo: (text: string) => void;
};

function NewTodo({ onAddTodo }: NewTodoProps) {
  const todoTextInputRef = useRef<HTMLInputElement>(null);

  function submitHandler(event: React.FormEvent) {
    event.preventDefault();

    const enteredText = todoTextInputRef.current!.value;

    if (enteredText.trim().length === 0) {
      // throw an error
    }

    onAddTodo(enteredText);
  }

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">
        Todo text
        <input type="text" id="text" ref={todoTextInputRef} />
      </label>
      <button type="submit">Add Todo</button>
    </form>
  );
}

export default NewTodo;

state with typescript

  • useState를 사용할 때, 기본값에 빈 배열을 전달하면, never[]가 타입으로 지정된다.
    • 언제나 배열이 비어있어야 한다는 뜻이다.
  • 그러나 이것은 원하는 동작이 아니기 때문에, 타입을 제네릭을 통해 지정한다.
const [todo, setTodo] = useState<todos[]>(todoInit);
  • state를 업데이트할 때는, concat()을 이용한다.
import NewTodo from 'components/NewTodo';
import Todos from 'components/Todo';
import { todos } from 'models/todo';
import { useState } from 'react';

const todoInit: todos[] = [
  {
    id: 1,
    text: 'Learn React',
  },
  {
    id: 2,
    text: 'Learn Typescript',
  },
];

let counter: number = 3;

function App() {
  const [todo, setTodo] = useState<todos[]>(todoInit);

  function onAddTodo(todoText: string) {
    const newTodo: todos = {
      id: counter,
      text: todoText,
    };
    counter += 1;

    setTodo((prevTodos) => {
      return prevTodos.concat(newTodo);
    });
  }

  return (
    <>
      <NewTodo onAddTodo={(text) => onAddTodo(text)} />
      <Todos items={todo} />
    </>
  );
}

export default App;

typescript 연습

  • props drilling이 발생했지만, 어차피 간소한 연습 프로젝트라서 별로 신경을 안썼다.
코드 보기
import NewTodo from 'components/NewTodo';
import Todos from 'components/Todo';
import { todos } from 'models/todo';
import { useState } from 'react';

const todoInit: todos[] = [
  {
    id: 1,
    text: 'Learn React',
  },
  {
    id: 2,
    text: 'Learn Typescript',
  },
];

let counter: number = 3;

function App() {
  const [todo, setTodo] = useState<todos[]>(todoInit);

  function onAddTodo(todoText: string) {
    const newTodo: todos = {
      id: counter,
      text: todoText,
    };
    counter += 1;

    setTodo((prevTodos) => {
      return prevTodos.concat(newTodo);
    });
  }

  function onRemoveTodo(todoId: number) {
    console.log(todoId);

    setTodo((prevTodos) => {
      // 제거할 항목을 제외한 새로운 배열을 생성하여 반환합니다.
      return prevTodos.filter((item) => item.id !== todoId);
    });
  }

  return (
    <>
      <NewTodo onAddTodo={(text) => onAddTodo(text)} />
      <Todos items={todo} onRemove={(id) => onRemoveTodo(id)} />
    </>
  );
}

export default App;
import { todos } from 'models/todo';
import TodoItem from './TodoItem';
import classes from './Todo.module.css';

type TodosProps = {
  items: todos[];
  onRemove: (id: number) => void;
};

function Todos({ items, onRemove }: TodosProps) {
  return (
    <ul className={classes.todos}>
      {items.map((item) => (
        <TodoItem
          text={item.text}
          id={item.id}
          onRemove={(id) => onRemove(id)}
          key={item.id}
        />
      ))}
    </ul>
  );
}

export default Todos;
import classes from './TodoItem.module.css';

type TodoProps = {
  text: string;
  id: number;
  onRemove: (id: number) => void;
};

function TodoItem({ text, id, onRemove }: TodoProps) {
  return (
    <li
      className={classes.item}
      onClick={() => onRemove(id)}
      aria-hidden="true"
    >
      {text}
    </li>
  );
}

export default TodoItem;

Context API with typescript

createContext

  • createContext는 제네릭 타입으로 정의되어 있기 때문에 <>를 통해 객체를 좀 더 자세히 정의한다.
  • <> 사이에 타입의 정의를 내려야 한다.
    • 인터페이스나 타입도 가능하다.
import React from 'react';

import { todos } from 'models/todo';

const TodosContext = React.createContext<{
  items: todos[];
  addTodo: () => void;
  removeTodo: (id: number) => void;
}>({
  items: [],
  addTodo: () => {},
  removeTodo: (id: number) => {},
});
  • 또는
import React from 'react';

import { todos } from 'models/todo';

type ContextType = {
  items: todos[];
  addTodo: () => void;
  removeTodo: (id: number) => void;
};

const TodosContext = React.createContext<ContextType>({
  items: [],
  addTodo: () => {},
  removeTodo: (id: number) => {},
});
import React from 'react';

import { todos } from 'models/todo';

interface ContextType {
  items: todos[];
  addTodo: () => void;
  removeTodo: (id: number) => void;
}

const TodosContext = React.createContext<ContextType>({
  items: [],
  addTodo: () => {},
  removeTodo: (id: number) => {},
});
  • 결국 이런 방식으로 하면 된다.
import React, { useCallback, useMemo, useState } from 'react';

import { todos } from 'models/todo';

type ContextType = {
  items: todos[];
  addTodo: (text: string) => void;
  removeTodo: (id: number) => void;
};

export const TodosContext = React.createContext<ContextType>({
  items: [],
  addTodo: () => {},
  removeTodo: (id: number) => {},
});

let counter: number = 3;

function TodosContextProvider({ children }: { children: React.ReactNode }) {
  const [todo, setTodo] = useState<todos[]>([]);

  function onAddTodo(todoText: string) {
    const newTodo: todos = {
      id: counter,
      text: todoText,
    };
    counter += 1;

    setTodo((prevTodos) => {
      return prevTodos.concat(newTodo);
    });
  }

  function onRemoveTodo(todoId: number) {
    setTodo((prevTodos) => {
      return prevTodos.filter((item) => item.id !== todoId);
    });
  }

  const contextValue: ContextType = useMemo(
    () => ({
      items: todo,
      addTodo: onAddTodo,
      removeTodo: onRemoveTodo,
    }),
    [todo],
  );

  return (
    <TodosContext.Provider value={contextValue}>
      {children}
    </TodosContext.Provider>
  );
}

export default TodosContextProvider;
  • Context를 소비할 때에는, 타입 추론이 자동으로 되어서 타입을 추가할 필요가 없다.
import { useContext } from 'react';

import { TodosContext } from 'store/todos-context';
import TodoItem from './TodoItem';
import classes from './Todo.module.css';

function Todos() {
  const todosCtx = useContext(TodosContext);

  return (
    <ul className={classes.todos}>
      {todosCtx.items.map((item) => (
        <TodoItem
          text={item.text}
          id={item.id}
          onRemove={(id) => todosCtx.removeTodo(id)}
          key={item.id}
        />
      ))}
    </ul>
  );
}

export default Todos;

'React 완벽 가이드' 카테고리의 다른 글

NextJS Deep Dive  (0) 2024.04.01
React Query/Tanstack Query  (0) 2024.04.01
Deploying React Apps  (0) 2024.04.01
User Authentication  (0) 2024.04.01
A Multi-Page SPA with React Router  (0) 2024.03.29