Предисловие

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

Базовые понятия

Прежде чем написать свой первый компонент-дженерик, нужно понять и научиться писать обычные Тайпскрипт Дженерики.

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

Проще написать, к сожалению, не получилось, поэтому предлагаю разобраться на примере.

Давайте представим, что у нас есть какая-то функция, которая имитирует запрос к серверу и отдает нам значение в виде обертки над нашим аргументом.

function request(arg: string) {
    return {
        status: 200,
        data: arg
    }
}

По коду выше наша функция будет иметь тип

function request(arg: string): {
    status: number;
    data: string;
}

Все, что написано выше - это, конечно, здорово, но давайте представим, что мы хотим работать с этой фукнцией не только с аргументом строкой.

Есть 3 варианта...

  • Использовать тип unknown или any

interface Response {
  status: number;
  data: unknown;
}

function request(arg: unknown): Response {
  return {
    status: 200,
    data: arg
  }
}
  • Написать еще одну функцию которая работает с числами

function request(arg: number) {
  return {
    status: 200,
    data: arg
  }
}
  • И конечно же использовать дженерик

function request<T>(arg: T) {
  return {
    status: 200,
    data: arg
  }
}

Что нам даст использование дженерика

function request<T>(arg: T) {
    return {
        status: 200,
        data: arg
    }
}

// Если мы вызовем request с числом
request(100)
// То request будет иметь тип
function request<number>(arg: number): {
    status: number;
    data: number;
}

// А если со строкой
request('100');
// То
function request<string>(arg: string): {
    status: number;
    data: string;
}

Если хотите более подробно познакомиться с дженериками, можете посмотреть документацию

Как пишутся компоненты-дженерики

Классовые:

class CustomComponent<T> extends React.Component<T> {
    // ...
}

Функциональные:

function CustomComponent<T> (props: React.PropsWithChildren<T>): React.ReactElement{
    // ...
}
const CustomComponent = <T, >(props: React.PropsWithChildren<T>: React.ReactElement => {
    // ...
}

Какие задачи решают

Давайте рассмотрим полезность на простом примере

interface CustomComponentProps<T> {
    data: T[]
    onClick: (element: T) => void;
}

class CustomComponent<T> extends React.Component<CustomComponentProps<T>> {
        
    render() {
        return (
            <div>
                <h1>Список</h1>
                <ul>
                    {
                        this.props.data.map((element) => (
                            <li onClick={() => this.props.onClick(element)}></li>
                        ))
                    }
                </ul>
            </div>
        )
    }
}

Что же нам даст использование этого компонента на деле

const AnotherCustomComponent: React.FC = () => {
    const data = ['text', 'text', 'text'];
    return (
        <div>
            <CustomComponent data={data} onClick={/* */} />
        </div>
    )
}

в примере выше CustomComponent будет ожидать в onClick функцию (element: string) => void так как в data был передан массив строк

Теперь давайте рассмотрим пример, где в качестве data у нас будет не массив примитивов, а массив сущностей

class User {
    constructor(
        public name: string,
        public lastName: string
    ){}

    get fullName() {
        return `${this.name} ${this.lastName}`
    }
}

const AnotherCustomComponent: React.FC = () => {
    const data = [
        new User('Джон', 'Сина'),
        new User('Дуэйн', 'Джонсон'),
        new User('Дейв', 'Батиста'),
    ];
    return (
        <div>
            {/* в он клик нам не придется ручками подставлять тип.
            		Как в примере выше, он сам выведется из того, что было передано в data */}
            <CustomComponent data={data} onClick={(user) => alert(user.fullName)} />
        </div>
    )
}

теперь функция onClick будет иметь тип (element: User) => void

Давайте рассмотрим немного другой пример

interface CustomComponentProps<T> {
    data: T[]
    onClick: (element: T) => void;
    // делаем children рендер функцией для элемента списка
    children: (element: T) => React.ReactElement
}

class CustomComponent<T> extends React.Component<CustomComponentProps<T>> {
        
    render() {
        return (
            <div>
                <h1>Список</h1>
                <ul>
                    {
                        this.props.data.map((element) => (
                            <li onClick={() => this.props.onClick(element)}>{this.props.children(element)}</li>
                        ))
                    }
                </ul>
            </div>
        )
    }
}

class User {
    constructor(
        public name: string,
        public lastName: string
    ){}

    get fullName() {
        return `${this.name} ${this.lastName}`
    }
}

const AnotherCustomComponent: React.FC = () => {
    const data = [
        new User('Джон', 'Сина'),
        new User('Дуэйн', 'Джонсон'),
        new User('Дейв', 'Батиста'),
    ];
    return (
        <div>
            <CustomComponent data={data} onClick={(user) => alert(user)}>
              {
                //   тут тип как в onClick тоже высчитается
                (user) => {
                    return <div>Пользователь: {user.fullName}</div>
                } 
              }  
            </CustomComponent>
        </div>
    )
}

Так как мы в data передавали массив юзеров children будет иметь тип (element: User) => React.ReactElement

По аналогии с дженериками-функциями в дженерики-компоненты можно явно определить нужный тип, с которым мы бы хотели работать, но такой синтаксис я редко использую

Пример валидного кода:

const AnotherCustomComponent: React.FC = () => {
    const data = [
        new User('Джон', 'Сина'),
        new User('Дуэйн', 'Джонсон'),
        new User('Дейв', 'Батиста'),
    ];
    return (
        <div>
            {/* тут мы явно задаем что мы хотим работать с юзером */}
            <CustomComponent<User>
      				data={data}
      				onClick={(user) => alert(user)}
						>
              {
                //   тут тип как в onClick тоже высчитается
                (user) => {
                    return <div>Пользователь: {user.fullName}</div>
                } 
              }  
            </CustomComponent>
        </div>
    )
}

Пример не валидного кода:

const AnotherCustomComponent: React.FC = () => {
    const data = [
        new User('Джон', 'Сина'),
        new User('Дуэйн', 'Джонсон'),
        new User('Дейв', 'Батиста'),
    ];
    return (
        <div>
            {/* тут мы явно задаем что мы хотим работать со строкой
            		и при передаче в data массив юзеров будет ошибка */}
            <CustomComponent<string>
      				data={data}
      				onClick={(user) => alert(user)}
						>
              {
                (user) => {
                    return <div>Пользователь: {user.fullName}</div>
                } 
              }  
            </CustomComponent>
        </div>
    )
}

Полезность на личном опыте

Реальные примеры из жизни не стал прикладывать, так как мои компоненты довольно жирненькие, поэтому решил показать полезность на псевдо примерах, но в качестве бонуса решил словами описать последнюю проблему, которую решил через компонент-дженерик.

Мне нужно было сделать списки для товаров и категорий, которые можно было драг-н-дропать, для изменения порядка отображения, и функционал у этих двух списков идентичен, кроме отображения. Я написал один компонент, который мог работать с драг-н-дропом, у него был пропс children который являлся рендер функцией для элемента, что мне и позволило менять отображение списка при одинаковом функционале.

Ссылка на gist

Заключение

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

Комментарии (2)


  1. faiwer
    13.09.2021 10:35

    Главная проблема это HoC-и. Они теряют все generic-и, т.к. в TS нет High Order Kinds. И приходится cast-ить типы руками. А так штука хорошая.


  1. Avantgarde87
    11.11.2021 15:26

    Статья зашла! Спасибо!
    Полезно))