React + TypeScript: необхідний мінімум

React + TypeScript

Чимало React-розробників запитують себе: чи треба мені вчити TypeScript? Ще як треба!

Переваги вивчення TS можуть бути зведені до наступного:

  • ваші шанси отримати більш високооплачувану роботу збільшаться;
  • у вашому коді буде набагато менше багів, його буде легше читати та підтримувати;
  • рефакторити код та оновлювати залежності стане набагато простіше.

Ця стаття являє собою мінімальне введення з використання TypeScript у React.

Основи TypeScript, необхідні React

Примітиви

Існує 3 основні примітиви, які є фундаментом для інших типів:

string // наприклад, "Pat"
boolean // наприклад, true
number // наприклад, 23 чи 1.99

Масиви

Тип масиву складається з примітивів чи інших типів:

number[] // наприклад, [1, 2, 3]
string[] // наприклад, ["Lisa", "Pat"]
User[] // кастомний тип, наприклад, [{ name: "Pat" }, { name: "Lisa" }]

Об’єкти

Об’єкти усюди. Приклад простого об’єкта:

const user = {
  firstName: "Pat",
  age: 23,
  isNice: false,
  role: "CTO",
  skills: ["HTML", "CSS", "jQuery"]
}

Тип, що описує цей об’єкт, виглядає так:

type User = {
  firstName: string;
  age: number;
  isNice: boolean;
  role: string;
  skills: string[];
}

Припустимо, що користувач має друзів, які також є користувачами:

type User = {
  // ...
  friends: User[];
}

Пет завжди ставив кар’єру на перше місце, тому в нього цілком може не бути друзів:

const user = {
  firstName: "Pat",
  age: 23,
  isNice: false,
  role: "CTO",
  skills: ["CSS", "HTML", "jQuery"],
  friends: undefined,
}

У TypeScript для позначення опціонального (необов’язкового) поля використовується символ ? після назви поля:

type User = {
  // ...
  friends?: User[];
}

Перерахування

Ми визначили тип поля role як string:

type User = {
  // ...
  role: string;
}

Пету це не подобається. Він вважає, що тип string недостатньо суворий. Його працівники мають вибирати з обмеженого набору ролей.

Для цього відмінно підійде перерахування (enumeration, enum):

enum UserRole {
  CEO,
  CTO,
  SUBORDINATE,
}

Так набагато краще. Але Пет знає, що значення елементів перерахування є числа. Значенням CEOє 0CTO– 1, а SUBORDINATE– 2. Пету це не до вподоби.

На щастя, як значення елементів перерахування можна використовувати рядки:

enum UserRole {
  CEO = "ceo",
  CTO = "cto",
  SUBORDINATE = "inferior-person",
}

Тепер Пет задоволений:

enum UserRole {
  CEO = "ceo",
  CTO = "cto",
  SUBORDINATE = "inferior-person",
}

type User = {
  firstName: string;
  age: number;
  isNice: boolean;
  role: UserRole;
  skills: string[];
  friends?: User[];
}

const user = {
  firstName: "Pat",
  age: 23,
  isNice: false,
  role: UserRole.CTO, // равняется "cto"
  skills: ["HTML", "CSS", "jQuery"],
}

Функції

Пет любить показувати свою владу, звільняючи підлеглих. Визначимо функцію, що дозволяє Пету робити це.

Типізація параметрів функції

Існує, як мінімум, 3 способи ідентифікувати звільнену особу. По-перше, ми можемо використовувати декілька параметрів:

function fireUser(firstName: string, age: number, isNice: boolean) {
  // ...
}

// чи так
const fireUser = (firstName: string, age: number, isNice: boolean) => {
  // ...
}

По-друге, ми можемо обернути параметри в об’єкт і визначити типи в об’єкті:

function fireUser({ firstName, age, isNice }: {
  firstName: string;
  age: number;
  isNice: boolean;
}) {
  // ...
}

Нарешті ми можемо винести параметри в окремий тип. Спойлер: така техніка часто використовується для проповнення компонентів React:

type User = {
  firstName: string;
  age: number;
  role: UserRole;
}

function fireUser({ firstName, age, role }: User) {
  // ...
}

Типізація значення, що повертається функцією

Пет вважає, що просто звільнити працівника недостатньо. Тому може бути гарною ідеєю повертати користувача з функції.

Для визначення типу значення, що повертається функцією, також є кілька способів. Ми можемо додати : Typeпісля закривання дужки списку параметрів функції:

function fireUser(firstName: string, age: number, role: UserRole): User {
  // ...
  return { firstName, age, role };
}

// чи так
const fireUser = (firstName: string, age: number, role: UserRole): User => {
  // ...
  return { firstName, age, role };
}

Якщо, наприклад, ми спробуємо повернути null отримаємо помилку:

null

Насправді, найчастіше ми не маємо необхідності визначати тип значення, що повертається функцією, явно. TypeScript відмінно справляється з припущенням (висновком) таких типів:

Code

За наявності сумнівів у коректності TypeScript типу, що виводиться, достатньо навести курсор на назву змінної або функції (спасибі сучасним редакторам коду).

Інші речі, з якими вам доведеться мати справу

Існує ще кілька корисних речей, які можуть знадобитися при використанні TypeScript у React. Мабуть, найважливішими з них є:

React і TypeScript

Коли мова заходить про використання TypeScript в React, слід пам’ятати, що компоненти і хуки React — це лише функції, а props — лише об’єкти.

Функціональні компоненти

У більшості випадків у нас немає необхідності визначати тип, який повертається компонентом:

function UserProfile() {
  return <div>If you're Pat: YOU'RE AWESOME!!</div>
}

Типом, що повертається компонентом є JSX.Element, як видно на наведеному нижче зображенні:

Code

Якщо ми спробуємо повернути з компонента не JSX, то отримаємо попередження:

Code

В даному випадку об’єкт user не є валідним JSX:

'UserProfile' cannot be used as a JSX component.
Its return type 'User' is not a valid JSX element.

Props

Як зазначалося раніше, props — це лише об’єкти:

enum UserRole {
  CEO = "ceo",
  CTO = "cto",
  SUBORDINATE = "inferior-person",
}

type UserProfileProps = {
  firstName: string;
  role: UserRole;
}

function UserProfile({ firstName, role }: UserProfileProps) {
    if (role === UserRole.CTO) {
    return <div>Hey Pat, you're AWESOME!!</div>
  }
  return <div>Hi {firstName}, you suck!</div>
}

// чи так
const UserProfile = ({ firstName, role }: UserProfileProps) => {
    if (role === UserRole.CTO) {
    return <div>Hey Pat, you're AWESOME!!</div>
  }
  return <div>Hi {firstName}, you suck!</div>
}

Зверніть увагу : при роботі над React-проектами ви, швидше за все, зустрінете багато коду, в якому використовується тип React.FC або React.FunctionComponent:

const UserProfile: React.FC<UserProfileProps>({ firstName, role }) {
  // ...
}

Використовувати ці типи більше не рекомендується .

Props-коллбеки

Як пропов компонентам часто передаються функції зворотного виклику (коллбеки):

type UserProfileProps = {
  id: string;
  firstName: string;
  role: UserRole;
  fireUser: (id: string) => void;
};

function UserProfile({ id, firstName, role, fireUser }: UserProfileProps) {
  if (role === UserRole.CTO) {
    return <div>Hey Pat, you're AWESOME!!</div>;
  }
  return (
    <>
      <div>Hi {firstName}, you suck!</div>
      <button onClick={() => fireUser(id)}>Fire this loser!</button>
    </>
  );
}

voidозначає, що функція нічого не повертає.

Дефолтні props

Як ви пам’ятаєте, ми можемо зробити поле опціональним за допомогою ?. Те саме можна робити з props:

type UserProfileProps = {
  age: number;
  role?: UserRole;
}

Опціональний props може мати значення за замовчуванням:

function UserProfile({ firstName, role = UserRole.SUBORDINATE }: UserProfileProps) {
  if (role === UserRole.CTO) {
    return <div>Hey Pat, you're AWESOME!!</div>
  }
  return <div>Hi {firstName}, you suck!</div>
}

Хуки

useState()

useState– Найпопулярніший хук React. У багатьох випадках його не слід типізувати. TypeScript здатний вивести типи значень, що повертаються useState() на основі початкового значення:

function UserProfile({ firstName, role }: UserProfileProps) {
  const [isFired, setIsFired] = useState(false);

  return (
    <>
      <div>Hi {firstName}, you suck!</div>
      <button onClick={() => setIsFired(!isFired)}>
        {isFired ? "Oops, hire them back!" : "Fire this loser!"}
      </button>
    </>
  );
}
Code

Тепер ми в безпеці. При спробі оновити стан не логічним значенням отримуємо помилку:

Code

У деяких випадках TypeSctipt не може вивести тип значень, що повертаються useState():

// TS не знає, элементи якого типу будуть у масиву
const [names, setNames] = useState([]);

// початковим значенням є `undefined`, тому TypeScript-у невідом справжній тип
const [user, setUser] = useState();

// теж саме справедливо для `null` в якості початкового значення
const user = useState(null);
Code

useState()реалізовано за допомогою загального типу (дженерика, generic type). Ми можемо використовувати це для типізації стану:

// типом `names` є `string[]`
const [names, setNames] = useState<string[]>([]);
setNames(["Pat", "Lisa"]);

// типом user є `User | undefined`
const [user, setUser] = useState<User>();
setUser({ firstName: "Pat", age: 23, role: UserRole.CTO });
setUser(undefined);

// типом `user` є `User | null`
const [user, setUser] = useState<User | null>(null);
setUser({ firstName: "Pat", age: 23, role: UserRole.CTO });
setUser(null);

Ми не будемо розглядати інші вбудовані хуки, вони типизуються схожим чином, за винятком того useEffect(), що не потребує типізації.

Хуки на замовлення

Кастомний хук – це просто функція:

function useFireUser(firstName: string) {
    const [isFired, setIsFired] = useState(false);
  const hireAndFire = () => setIsFired(!isFired);

    return {
    text: isFired ? `Oops, hire ${firstName} back!` : "Fire this loser!",
    hireAndFire,
  };
}

function UserProfile({ firstName, role }: UserProfileProps) {
  const { text, hireAndFire } = useFireUser(firstName);

  return (
    <>
      <div>Hi {firstName}, you suck!</div>
      <button onClick={hireAndFire}>
        {text}
      </button>
    </>
  );
}

Події

З вбудованими обробниками подій у React працювати легко, оскільки TypeScript-у відомі типи подій:

function FireButton() {
  return (
    <button onClick={(event) => event.preventDefault()}>
      Fire this loser!
    </button>
  );
}

Але визначення оброблювача у вигляді окремої функції докорінно змінює справу:

function FireButton() {
  const onClick = (event: /* ??? */) => {
    event.preventDefault();
  };

  return (
    <button onClick={onClick}>
      Fire this loser!
    </button>
  );
}

Який тип має event? Існує 2 підходи:

  • гуглити (не рекомендується, викликає запаморочення));
  • приступити до реалізації вбудованої функції та дозволити TS вивести типи:
Code

Щасливі копіпастінг. Нам навіть не потрібно розуміти, що відбувається (більшість обробників є дженериками, як useState()).

function FireButton() {
  const onClick = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    event.preventDefault();
  };

  return (
    <button onClick={onClick}>
      Fire this loser!
    </button>
  );
}

Що щодо оброблювача зміни значення інпуту?

function Input() {
  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    console.log(event.target.value);
  };

  return <input onChange={onChange} />;
}

А команда?

function Select() {
  const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    console.log(event.target.value);
  };

  return <select onChange={onChange}>...</select>;
}

Дочірні компоненти

Композиція компонентів, від якої ми всі в захваті, передбачає передачу пропа children:

type LayoutProps = {
  children: React.ReactNode;
};

function Layout({ children }: LayoutProps) {
  return <div>{children}</div>;
}

Тип React.ReactNodeнадає більшу свободу вибору переданого значення. Він дозволяє передавати майже будь-що (крім об’єкта):

Code

Якщо як props повиннa передаватися тільки розмітка, тип childrenможна обмежити до React.ReactElementабо JSX.Element(що по суті те саме):

type LayoutProps = {
  children: React.ReactElement; // чи `JSX.Element`
};

Ці типи є набагато суворішими:

Code

Сторонні бібліотеки

Додавання типів

Сьогодні багато сторонніх бібліотек містять відповідні типи. У цьому випадку окремий пакет (з типами) не потрібно встановлювати.

Типи для великої кількості існуючих бібліотек містяться в репозиторії DefinitelyTyped на GitHub і публікуються під егідою організації @types(включаючи типи React). При встановленні пакета без типів та його імпорті отримуємо таку помилку:

Code

Копіюємо виділену команду та виконуємо її в терміналі:

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

Якщо ви все ж таки зіткнулися з відсутністю типів для сторонньої бібліотеки, доведеться визначити глобальні типи самостійно у файлі .d.ts(ми не розглядатимемо його в рамках статті).

Використання дженериків

Бібліотеки розраховані на різні випадки використання, тому вони мають бути гнучкими. Для забезпечення гнучкості типів використовуються дженерики. Ми їх уже бачили в useState():

const [names, setNames] = useState<string[]>([]);

Такий прийом дуже поширеним для сторонніх бібліотек. Приклад з Axios:

import axios from "axios"

async function fetchUser() {
  const response = await axios.get<User>("https://example.com/api/user");
  return response.data;
}
Code

Реагувати на запит:

import { useQuery } from "@tanstack/react-query";

function UserProfile() {
  // загальні типи даних і помилки
  const { data, error } = useQuery<User, Error>(["user"], () => fetchUser());

  if (error) {
    return <div>Error: {error.message}</div>;
  }
  // ...
}

Стилізовані компоненти:

import styled from "styled-components";

// загальний тип для props
const MenuItem = styled.li<{ isActive: boolean }>`
  background: ${(props) => (props.isActive ? "red" : "gray")};
`;

function Menu() {
  return (
    <ul>
      <MenuItem isActive>Menu Item 1</MenuItem>
    </ul>
  );
}

Способи усунення несправностей

Початок роботи з React & TypeScript

Створення нового React-проекту з підтримкою TypeScript – справа однієї команди. Я рекомендую використовувати шаблони Vite+React+TS або Next.js+TS:

npm create vite [project-name] --template react-ts

npx create-next-app [project-name] --ts

Виявлення правильного типу

Ми це вже розглядали, але повторимо ще раз: починаємо писати вбудовану функцію та дозволяємо TypeScript вивести правильний тип події:

Code

За наявності сумнівів щодо кількості доступних параметрів набираємо (...args) => та отримуємо відповідний масив:

Code

Вивчення типу

Найпростіший спосіб отримати список всіх доступних полів типу – використовувати автозавершення в IDE. Для цього достатньо натиснути CTRL + Пробіл (Windows) або Option + Пробіл (Mac):

Code

Щоб перейти до визначення типу, слід натиснути CTRL + Click (Windows) або CMD + Click (Mac):

Code

Читання повідомлень про помилки

Повідомлення про помилки та попередження TS є дуже інформативними, головне – навчитися їх правильно читати. Розглянемо приклад:

function Input() {
  return <input />;
}

function Form() {
  return (
    <form>
      <Input onChange={() => console.log("change")} />
    </form>
  );
}

Ось що показує TS:

Code

Що це означає? Що ще за тип IntrinsicAttributes? При роботі з бібліотеками (у тому числі з самим React) ви часто зустрічатимете дивні назви типів, на кшталт цього.

Моя порада: ігноруйте їх спочатку.

Найважливішою частиною є останній рядок:

Property 'onChange' does not exist on type...

Дивимося на визначення компонента Input:

function Input() {
  return <input />;
}

У нього немає пропа onChange. Саме це “не подобається” TypeScript.

Тепер розглянемо складніший приклад:

const MenuItem = styled.li`
  background: "red";
`;

function Menu() {
  return <MenuItem isActive>Menu Item</MenuItem>;
}
Code

Нічого собі повідомлення про помилку! Без паніки: прокручуємо в кінець повідомлення – як правило, відповідь знаходиться там:

Code

Перетини

Припустимо, що у нас є тип User, визначений в окремому файлі, наприклад types.ts:

export type User = {
  firstName: string;
  role: UserRole;
}

Він використовується для типізації пропов компонента UserProfile:

function UserProfile({ firstName, role }: User) {
  // ...
}

Який використовується в компоненті UserPage:

function UserPage() {
  const user = useFetchUser();

  return <UserProfile {...user} />;
}

Поки все добре. Але що якщо UserProfile буде приймати ще один проп – функцію fireUser?

function UserProfile({ firstName, role, fireUser }: User) {
  return (
    <>
      <div>Hi {firstName}, you suck!</div>
      <button onClick={() => fireUser({ firstName, role })}>
        Fire this loser!
      </button>
    </>
  );
}

Отримуємо помилку:

Code

Цю проблему можна вирішити за допомогою перетину (intersection type). При перетині всі поля двох типів поєднуються в один тип. Перетини створюються за допомогою символу &:

type User = {
  firstName: string;
  role: UserRole;
}

// `UserProfileProps` буде вміщувати в себе всі поля `User` та `fireUser`
type UserProfileProps = User & {
    fireUser: (user: User) => void;
}

function UserProfile({ firstName, role, fireUser }: UserProfileProps) {
  return (
    <>
      <div>Hi {firstName}, you suck!</div>
      <button onClick={() => fireUser({ firstName, role })}>
        Fire this loser!
      </button>
    </>
  );
}

Більш “чистим” способом є визначення окремих типів для компонентів, що приймаються props:

type User = {
  firstName: string;
  role: UserRole;
}

// !
type UserProfileProps = {
  user: User;
  fireUser: (user: User) => void;
}

function UserProfile({ user, onClick }: UserProfileProps) {
  return (
    <>
      <div>Hi {user.firstName}, you suck!</div>
      <button onClick={() => fireUser(user)}>
        Fire this loser!
      </button>
    </>
  );
}

Дякую за увагу та happy coding!

Переклад статті “Minimal TypeScript Crash Course For React