Kategoria:React

React & TypeScript - komponenty generyczne

  • Czas potrzebny na przeczytanie:5 minut
  • Opublikowane:

Czy połączenie TypeScript & React musi się ograniczać do typowania propsów? Oczywiście, że nie! W samym TS istnieje wiele bardziej zaawansowanych mechanizmów, które możemy przekuć do naszych aplikacji Reactowych. Jednym z nich są funkcje generyczne. Jak dzięki nim możemy budować w pełni otypowane, dynamiczne komponenty w React?

Zanim zaczniemy, jeśli jeszcze nie jesteś zaznajomiony z tematem typów i funkcji generycznych, to zapraszam Cię do sprawdzenia mojego osobnego artykułu na ten temat - ta wiedza będzie niezbędna do zrozumienia komponentów generycznych w React.

Generyczne komponenty

Skoro w TypeScripcie mamy możliwość pisania funkcji generycznych, a w React komponenty to tylko funkcje, to nic nie stoi nam na przeszkodzie, żeby stworzyć generyczny komponent, zobacz jak to wygląda:

type GenericComponentProps<T> = {
  prop1: T;
  prop2: Array<T>;
};

function GenericComponent<T>({ prop1, prop2 }: GenericComponentProps<T>) {}

Deklarujemy tutaj generyczny typ GenericComponentProps<T>, który posłuży nam do otypowania propsów, a mówiąc bardziej po TypeScriptowemu, po prostu parametrów funkcji. Wszystko fajnie, tylko właściwie po co mielibyśmy to robić? Komponenty generyczne sprawdzą się świetnie w takich dwóch podstawowych przypadkach:

  1. Gdy chcemy, aby nasz komponent był elastyczny, jeśli chodzi o typy. Nie nadajemy wtedy sztywnego typu dla np. tablicy elementów, ale przypisujemy jej dynamiczny, generyczny typ.
  2. Gdy chcemy, aby nasze propsy były ze sobą powiązane. Dokładnie tak robimy w tym przypadku, prop1 dostaje typ T, a prop2, jest po prostu tablicą elementów o typie T.

Wiemy już czym są, kiedy powinniśmy z nich korzystać i w jaki sposób to robić, przejdź więc do bardziej praktycznego przykładu...

Założenia: chcemy stworzyć komponent tabelki, który będzie przyjmował dane oraz kolumny. Propsy mają być dynamiczne, chociaż w pewien sposób ograniczone i zależne od siebie.

Tworzymy komponent Table, tym razem przyjmuje on dwa typy zamiast jednego. Temu drugiemu nadajemy ograniczenia, korzystając ze specjalnego słówka extends, parametr Key może być tylko kluczem Row. Następnie przekazujemy typy do tajemniczego TableProps, a w JSX renderujemy tabelę:

const Table = <Row, Key extends keyof Row>({ data, columns }: TableProps<Row, Key>) => (
  <table>{renderTable(data, columns)}</table>
);

Wewnątrz TableProps deklarujemy typy dla data, columns i funkcji renderTable. Wszystkie te propsy korzystają z generycznych parametrów i są powiązane ze sobą:

type Column<Row, Key extends keyof Row> = {
  key: Key;
  header: string;
};

type TableProps<Row, Key extends keyof Row> = {
  data: Array<Row>;
  columns: Array<Column<Row, Key>>;
  renderTable: (row: Row, column: Column<Row, Key>) => React.ReactNode;
};

const Table = <Row, Key extends keyof Row>({ data, columns }: TableProps<Row, Key>) => (
  <table>{renderTable(data, columns)}</table>
);

Zostało nam tylko wywołanie komponentu, ale zanim to zrobimy, zamodelujmy dane:

type Column<Row, Key extends keyof Row> = {
  key: Key;
  header: string;
};

type Character = Readonly<{
  id: number;
  name: string;
  status: 'Dead' | 'Alive';
  species: string;
}>;

const characters: Character[] = [
  {
    id: 1,
    name: 'Rick Sanchez',
    species: 'Human',
    status: 'Alive',
  },
  {
    id: 2,
    name: 'Morty Smith',
    species: 'Human',
    status: 'Alive',
  },
  {
    id: 4,
    name: 'Alexander',
    species: 'Human',
    status: 'Dead',
  },
];

const columns: Column<Character, keyof Character>[] = [
  {
    key: 'id',
    header: 'Id',
  },
  {
    key: 'name',
    header: 'Name',
  },
  {
    key: 'species',
    header: 'Species',
  },
  {
    key: 'status',
    header: 'Status',
  },
];

Nadałem typ Column<Row,Key> dla danych columns, żeby zaprezentować Ci jak będzie działał nasz komponent. Gdybyśmy teraz spróbowali podać key, który nie jest kluczem obiektu Character, dostalibyśmy błąd i dokładnie o to nam chodziło! Najlepsze jest to, że podczas wywołania komponentu, nie musimy nic więcej robić niż zwykle:

Generyczne wyrażenie funkcyjne

Kolejnym fajnym przykładem użycia komponentów generycznych jest Select, tutaj podobnie jak poprzednio propsy są pewien sposób dynamiczne i zależne od siebie:

type Option<Value> = {
  value: Value;
  name: string;
};

type SelectProps<Value> = {
  options: Array<Option<Value>>;
  value: Value;
};

const Select = <Value>({ options, value }: SelectProps<Value>) => {}; // Błąd!

Tylko jest jeden mały problem. TypeScript, przy generycznych wyrażeniach funkcyjnych (komponenty pisane z const), nie rozumie, że chcemy zadeklarować tam generyka i rzuca błędami. Błąd występuje tylko, gdy żaden z naszych parametrów nie jest ograniczany. Jak to naprawić:

  1. Zamienić sposób zapisu z const na function
  2. Ograniczyć typ o unknown - const Select = <Value extends unknown>() => {}
  3. Wprowadzić przecinek po parametrze - const Select = <Value, >() => {}

Generyki z React.memo()/React.forwardRef()

Kolejnym problemem, który wiąże się z generycznymi komponentami, jest memoizacja za pomocą React.memo(). Memoizowane komponenty trochę się gryzą z tymi generycznymi i nie możemy zrobić czegoś takiego:

Jak to ogarnąć? Wystarczy najpierw stworzyć komponent, a dopiero później opakować go w React.memo(). Pozostaje jeszcze kwestia typów, tutaj z pomocą przychodzi asercja na oryginalny komponent:

Komponent:

Memoizacja:

Dokładnie taki sam problem występuje przy korzystaniu z React.forwardRef(). Spośród dostępnych rozwiązań, ja osobiście skłaniam się ku temu samemu co w przypadku memo():

Komponent:

import type { Ref } from 'react';

const _Select = <Value extends string>(
  { options, value }: SelectProps<Value>,
  ref: Ref<HTMLSelectElement>,
) => <select ref={ref}></select>;

ForwardRef:

Inferencja typów

Ostatni z naszych tematów, czyli inferecja typów. Podczas wywoływania komponentu <Table /> nie podawaliśmy nic więc, TypeScript sam domyślił się jaki ma przekazać typ. A co jeśli chcielibyśmy przekazać typ na sztywno tak jak możemy to zrobić w zwykłych funkcjach?

Zobaczmy jak do tego tematu podeszli twórcy Formika. Ich główny komponent jest komponentem generycznym i jako pierwszy parametr przyjmuje Values. Przy jego wywołaniu podajemy tzw. initialValues, TypeScript na bazie obiektu, sam wywnioskuje jego typ:

export function Formik<Values extends FormikValues = FormikValues, ExtraProps = {}>(
  props: FormikConfig<Values> & ExtraProps,
) {
  const formikbag = useFormik<Values>(props);
}

<Formik
  initialValues={{
    email: false, // Wszystko ok!
  }}
/>;

A co jeśli chcielibyśmy powiedzieć temu komponentowi, że ma przyjmować początkowe wartości tylko o określonych typach? Wystarczy przekazać do komponentu typ w taki sposób:

type FormValues = {
  email: string;
};

<Formik<FormValues>
  initialValues={{
    email: false, // Błąd!
  }}
/>;

Oczywiście moglibyśmy po prostu przypisać typ dla obiektu, ale ta opcja wygląda bardziej cool 😎

Podsumowanie

Typy generyczne są potężnym mechanizmem, zarówno w czystym TS, jak i w tym w React. Nie bój się korzystać z generycznych komponentów, wtedy, kiedy ma to sens. Dzięki nim nasze aplikacje mogą stać się o wiele bardziej type-safety.

Do usłyszenia!

Źródła

O autorze

Olaf Sulich

Olaf jest Frontend Developerem, blogerem i nosi rybacki kapelusz 🎩 Pisze o wszystkim co związane z frontendem, ale nie boi się backendu i designów 🦾 Ma głowę pełną pomysłów i nadzieję, że znajdziesz tutaj coś dla siebie!

Dołącz do społeczności!

Bo w programowaniu liczą się ludzie

Wchodzę