Иногда инженерные усовершенствования начинаются не с грандиозного плана, а с проявления любопытства к деталям.

Однажды, работая над React-компонентом, я заметил нечто странное.

Когда я напрямую указывал пропсы компонента, TypeScript сигнализировал о неиспользуемом пропсе. Но когда я оборачивал тот же компонент в React.FC, предупреждение исчезало.

Мне показалось это неправильным. TypeScript должен защищать нас от ошибок, а не просто игнорировать их.

Я открыл TypeScript Playground и провел несколько экспериментов. Действительно, React.FC изменял способ обработки пропсов компилятором. Создавалось впечатление, будто TypeScript вежливо говорил: «Не волнуйся, все в порядке», даже когда все было не так.

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

Привычный паттерн со скрытыми издержками

В Gusto многие наши React-компоненты следовали привычному паттерну:

type ButtonProps = {
  label: string
  onClick?: () => void
}

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
)

Несколько лет назад это соглашение имело смысл. React.FC выглядел чисто и удобно и широко рекомендовался в ранних руководствах по TypeScript и React.

Но когда я начал копать глубже, стали проявляться изъяны.

React.FC выполняет несколько неочевидных действий, которые могут незаметно вводить разработчиков в заблуждение:

  • позволяет передавать недопустимые значения пропсов по умолчанию без предупреждения об ошибках;
  • скрывает неиспользуемые пропсы, поэтому TypeScript не предупреждает о мертвом коде;
  • нарушает вывод типов для дженериков, что усложняет типизацию переиспользуемых компонентов.

По отдельности эти проблемы едва заметны. Вместе же они превращаются в технический долг, которого TypeScript как раз и должен помогать избегать.

Таким образом, мы незаметно накапливали технический долг.

Момент истины

Проблема стала очевидной, когда я сравнил две версии одного и того же компонента — одну с типизацией через React.FC, другую без:

// С React.FC — без предупреждения
const Example: React.FC<{ used: string; unused: string }> = ({ used }) => {
  return <div>{used}</div>
}

//  Без React.FC — корректное предупреждение
const Example = ({ used }: { used: string; unused: string }): React.ReactNode => {
  return <div>{used}</div>
}

В первом случае TypeScript не предупреждал о том, что неиспользуемая переменная не используется. Во втором он делал именно то, что мы от него ожидаем.

В этот момент мы поняли: наша система типов настолько же хороша, насколько хороши типы, которым мы решили доверять. В данном случае React.FC давал обещания, которые не мог сдержать.

От обсуждений к изменениям во всей кодовой базе

Я поделился своим открытием в чате разработчиков. Сначала это был просто пост в духе «сегодня я узнал» — такой, который может собрать пару реакций в виде эмодзи и исчезнуть. Но этот пост вызвал настоящую дискуссию. Другие инженеры подключились, делясь похожими разочарованиями и припоминая подобные странности TypeScript.

Сразу стало ясно: мы не одиноки — и действительно можем с этим что-то сделать.

Здесь стоит сделать отступление и рассказать о культуре инженерии Gusto, потому что она сыграла ключевую роль в том, что произошло дальше. В Gusto реализация любой идеи  редко воспринимается как результат индивидуальной работы. Сотрудничество — не просто абстрактная ценность; оно подкреплено конкретными структурами, которые облегчают выдвижение идей и получение содержательной обратной связи.

У нас есть гильдии, протоколы архитектурных решений (ADR), ревью технических спецификаций, график рабочих совещаний и открытые Slack-каналы, которые создают четкие пути для обсуждения. Если вы хотите поднять вопрос, касающийся фронтенда, можете прийти на совещание веб-команды. Для обсуждения изменений, затрагивающих общую инфраструктуру или соглашения, существуют устоявшиеся каналы и форумы, где такие обсуждения проходят естественным путем. А если идея требует более широкого согласования, она может быть оформлена через ADR и открыто рассмотрена.

Эта среда сыграла решающую роль. То, что начиналось как случайное наблюдение, не застряло на этапе «интересно, но рискованно». Напротив, оно быстро нашло соавторов, скептиков и сторонников — людей, которые помогли проверить идею на прочность и превратить ее в нечто осуществимое.

В течение нескольких дней мы решили выяснить, насколько масштабна проблема. И убедились: очень масштабна. Наш главный фронтенд-репозиторий содержал тысячи компонентов, типизированных с помощью React.FC. Почти каждый пакет должен был подвергнуться исправлениям.

Это было амбициозно — возможно, даже немного безрассудно. Но у нас были инструменты, поддержка и общее понимание, что результат — настоящая, обеспечиваемая безопасность типов — того стоил.

Автоматизация изменений

Шаг 1: Переписывание объявлений компонентов

Мы написали скрипт трансформации, который просканировал всю кодовую базу на предмет компонентов, определенных через React.FC. Для каждого из них он выполнял следующее:

  • извлекал тип пропсов из React.FC<Props>;
  • встраивал этот тип непосредственно в параметры компонента;
  • добавлял явный возвращаемый тип (React.ReactNode).
// До
const Card: React.FC<CardProps> = ({ title }) => <div>{title}</div>

// После
const Card = ({ title }: CardProps): React.ReactNode => <div>{title}</div>

Результат стал проще, строже и понятнее для TypeScript.

Шаг 2: Обеспечение явных возвращаемых типов

Затем мы гарантировали, что каждый экспортируемый компонент явно объявляет свой возвращаемый тип. Если компонент возвращал JSX, но не указывал тип, мы автоматически добавляли его:

// До
export function Modal({ isOpen }: ModalProps) {
  return isOpen ? <div>Open</div> : null
}

// После
export function Modal({ isOpen }: ModalProps): React.ReactNode {
  return isOpen ? <div>Open</div> : null
}

Это дало каждому компоненту четкий контракт — и TypeScript мог его обеспечивать.

Волновой эффект от чистки

Как только миграция была запущена, произошло нечто неожиданное: компилятор начал выявлять ошибки, о существовании которых мы не подозревали:

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

Убрав одну абстракцию, мы обнаружили десятки мелких проблем, которые оставались незамеченными. То, что начиналось как чистка типов, превратилось в значимое улучшение общего состояния кода.

Как мы сохранили этот результат

После миграции нам нужно было убедиться, что React.FC не вернется. Поэтому мы добавили правило для линтера, чтобы полностью заблокировать его:

"@typescript-eslint/no-restricted-types": [
  "error",
  {
    "types": {
      "FC": {
        "message": "Avoid React.FC. Use explicit prop types and return values instead."
      }
    }
  }
]

Теперь это правило автоматически обеспечивает соблюдение нового соглашения.

Чего мы добились

Результаты нашей работы к завершению проекта:

  • обновлено более 5000 файлов;
  • стандартизированы десятки фронтенд-пакетов;
  • исправлены десятки скрытых ошибок;
  • повышена предсказуемость и надежность системы типов.

Но настоящая победа была не только технической. Она была и культурной. Этот проект напомнил нам, что инженерное мастерство часто проистекает из внимательности — из умения замечать то, что кажется неправильным, и не успокаиваться, пока не докопаешься до сути проблемы.

Заключение

Отказ от React.FC был не проявлением педантизма — это было стремление к непогрешимости. Выбрав явную, предсказуемую типизацию вместо сомнительного удобства, мы получили более надежные гарантии и более чистую основу для будущего.

Иногда прогресс заключается не в принятии самого нового инструмента, а в пересмотре тех, которые мы безоговорочно приняли.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Arda Örkin: The Journey to a Safer Frontend: Why We Removed React.FC

Предыдущая статьяКогда код становится видом искусства