Kategoria:React

React Children & TypeScript - jak to ogarnąć?

  • Czas potrzebny na przeczytanie:10 minut
  • Opublikowane:

Koncepcja dzieci w Reakcie pojawia się nam już w bardzo wczesnych etapach nauki. Ich sposób działania po chwili okazuje się bardzo prosty, ale w praktyce children oferują znacznie więcej, są trochę tricky i potrafią zaskoczyć 🤔 Jak więc sobie z nimi radzić i na co uważać podczas ich wykorzystania? Jedziemy z tematem!

Podstawy

Jeśli do tej pory nie słyszałeś o dzieciach w React, to polecam na starcie odwiedzić oficjalną dokumentację. A my jedziemy z powtórką! Dzieci są specjalnymi propsami, które pomagają nam w komponowaniu komponentów. Spójrz na przykład:

type ListProps = {
  children: '?';
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const App = () => (
  <List>
    <li>Apple 🍏</li>
    <li>Banana 🍌</li>
    <li>Strawberry 🍓</li>
  </List>
);

Tworzymy dwa komponenty, pierwszy <List>, który będzie przyjmował dzieci, oraz komponent <App>, który będzie wywoływał ten pierwszy. Podczas wywołania komponentu <List> do jego środka przekazujemy np. elementy HTML, ot cała filozofia... Tylko, że nie do końca!

Typy

I tutaj zaczyna się prawdziwa zabawa. W teorii children mogą być wszystkim, jednak często chcemy, aby były czymś bardziej konkretnym, spójrzmy na dostępne opcje:

ReactNode

Typ React.ReactNode to najpopularniejsza z opcji. Korzysta z niej np. niepolecany przeze mnie typ React.FC. Wracając, React.ReactNode jest bardzo giętkim typem i przepuszcza przez nasze dzieci dosłownie wszystko: portal, tekst, liczby, boolean, elementy, komponenty...

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

const List = ({ children }: ListProps) => <div>{children}</div>;

const App = () => (
  <List>
    <li>Apple 🍏</li>
    Banana 🍌
    {['Strawberry 🍓']}
    {false}
  </List>
);

Z typem React.ReactNode wiąże się pewien problem, ale to bardzo rzadki case - dla ciekawskich zostawiam issue na GitHubie.

ReactElement

Dzięki React.ReactElement nasze children będą mogły być tylko komponentem/elementem HTML:

type ListProps = {
  children: Array<React.ReactElement>;
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const ListItem = () => <span>Apple 🍏</span>;

const App = () => (
  <List>
    <ListItem />
    <p>Banana 🍌</p>
    <h2>Strawberry 🍓</h2>
  </List>
);

ReactText

Typ React.ReactText akceptuje tylko ciąg znaków/liczb. Zdecydowanie lepsza opcja jeśli chcemy wprowadzić takie ograniczenia:

type ListProps = {
  children: React.ReactText;
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const App = () => <List>Apple 🍏 Banana 🍌 Strawberry 🍓</List>;

ReactChild

Ten typ jest połączeniem (a taka naprawdę unią) typów ReactElement i ReactText:

type ListProps = {
  children: React.ReactChild;
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const App = () => (
  <List>
    Apple 🍏 <li>Banana 🍌</li> Strawberry 🍓
  </List>
);

Funkcje

Dziećmi mogą być oczywiście również funkcje, szczególnie przydatne przy patternie render props:

type ListProps = {
  children: () => React.ReactElement;
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const App = () => <List>{() => <li>Apple 🍏</li>}</List>;

JSX.Element

Często możemy się spotkać z typem JSX.Element, jest to typ zwracany z metody React.createElement(). To tak naprawdę tylko nakładka na React.ReactElement i pod spodem wygląda tak:

Czy warto więc go stosować do typowania children? Ja osobiście wolę korzystać z wspomnianych wcześniej typów z prefixem React, ale jak to już bywa w Reakcie - pełna dowolność.

More technical explanation is that a valid React node is not the same thing as what is returned by React.createElement. Regardless of what a component ends up rendering, React.createElement always returns an object, which is the JSX.Element interface, but React.ReactNode is the set of all possible return values of a component. ~ Feraber

React.FC

Przyszła pora na gwiazdę wieczoru - typ React.FC. Osobiście odradzam jego stowanie, ponieważ wiążą się z nim pewne problemy... Najważniejszym z nich jest to, że ten typ z automatu typuje children jako niewymagane ReactNode. Może to rodzić podobne sytuacje:

const List: React.FC = () => <ul></ul>;

const App = () => (
  <List>
    <li>Apple 🍏</li>
    <li>Banana 🍌</li>
    <li>Strawberry 🍓</li>
  </List>
);

Co więc zamiast React.FC? Wystarczy normalne typowanie, tak jak w przypadku funkcji.

Zawężenie typu

Załóżmy, że w naszym kodzie chcemy zadeklarować, tak jak wyżej, komponent <List>, który będzie nieoznaczoną listą. Z racji tego, że dziećmi <ul> mogą być tylko <li>, chcieli byśmy nałożyć ograniczenie dla children na ten konkretny tag, czy to możliwe?

Na pierwszy strzał niech poleci React.ReactElement, jest on typem generycznym do którego możemy podać konkretny element, czy to wystarczy?

type ListProps = {
  children: React.ReactElement<'li'>;
};

const List = ({ children }: ListProps) => <ul>{children}</ul>;

const App = () => (
  <List>
    <li>Apple 🍏</li>
    Banana 🍌 Strawberry 🍓
  </List>
);

Okazuję się, że nie ❌ Spróbujmy jeszcze inaczej:

const ListItem = ({ name }: { name: string }) => <li>{name}</li>;

type ListProps = {
  children: React.ReactElement<'li'>; // ❌
  children: JSX.InstrictElements['li']; // ❌
  children: typeof ListItem; // ❌
  ... //  ❌
};

Te i inne opcje po prostu nie zadziałają tak jakbyśmy tego oczekiwali... Dzieje się to z tego względu, że komponenty/elementy zamieniane są w locie na typ JSX.Element, a szkoda.

Nie jakość, a ilość

Co pozostaje nam, biednym Reaktowcom, jeśli nie możemy wskazać dokładnego typu? Wskazanie dokładnej ilości! W naszym typie możemy wskazać czy children będą np. pojedynczym elementem, tablicą elementów lub tuplą (tablica o określonej długości i określonych typach):

import { ReactElement, ReactText, ReactFragment } from 'react';

type ListProps = {
  children: ReactElement;
  children: Array<ReactElement>;
  children: [ReactElement, ReactElement, ReactElement];
  children: [ReactElement, ReactText, ReactFragment];
};

Metody iteracyjne

React oprócz samego mechanizmu komponentów rodziców i dzieci udostępnia nam również szereg metod dla API React.Children. Nie będę Ci tutaj przytaczał wszystkich, są one bardzo dobrze opisane w dokumentacji. Chciałbym jednak wrócić do naszego problemu, wiemy już, że nie możemy w pełni bezpiecznie otypować dzieci, jak więc obejść ten problem?

  1. Deklarujemy typ dla children, ustawiamy go jako tablicę React.ReactElement
  2. Korzystamy z metody toArray obiektu Children, która zwraca dzieci jako płaską strukturę
  3. Sprawdzamy, czy aby na pewno nasz child jest elementem Reaktowym. Dlaczego tak, skoro w propsach mamy już pewność? W wyniku metody toArray każde z naszych dzieci będzie miało typ ReactChild | ReactFragment | ReactPortal, my chcemy mieć pewność, że jest to ReactElement. Moglibyśmy do tego celu stworzyć dedykowanego type guarda, ale React nas wyręcza i udostępnia funkcję isValidElement
  4. Sprawdzamy, czy typ dziecka jest równy typowi jaki posiada <li>
  5. Jeśli tak, klonujemy i zwracamy child
  6. Jeśli nie, wrzucamy child w tag <li>
  7. Całość opakowujemy w zmienną i przekazujemy do <ul>

import { Children, ReactElement, isValidElement, cloneElement } from 'react';

type ListProps = {
  // #1
  children: Array<ReactElement>;
};

const List = ({ children }: ListProps) => {
  // #2
  const newChildren = Children.toArray(children).map((child) => {
    // #3
    if (isValidElement(child)) {
      // #4
      if (child.type === 'li') {
        // #5
        return cloneElement(child);
      }
      // #6
      return <li key={child.key}>{child}</li>;
    }
  });
  // #7
  return <ul>{newChildren}</ul>;
};

const App = () => (
  <List>
    <li>Apple 🍏</li>
    <a href="banana.html">Banana 🍌</a>
    <p>Strawberry 🍓</p>
  </List>
);

Problemy z React.Fragment

Wszystko byłoby pięknie, gdyby nie React.Fragment. Domyślnie metoda React.Children.toArray() spłaszcza nam strukturę children, ale... No właśnie, pozostawia fragmenty. Problem pojawia się gdy do naszej listy podamy dzieci w takiej formie:

<List>
  <li>Apple 🍏</li>
  <>
    <a href="banana.html">Banana 🍌</a>
    <p>Strawberry 🍓</p>
  </>
</List>

Zamiast trzech <li> zostaną wyrenderowane dwa. Jak temu zaradzić? Powstała biblioteka specjalnie pod ten problem, która udostępnia funkcje flattenChildren. Zobaczmy na cały, poprawiony przykład:

import { Children, ReactElement, isValidElement, cloneElement } from 'react';
import flattenChildren from 'react-keyed-flatten-children';

type ListProps = {
  children: Array<ReactElement>;
};

const List = ({ children }: ListProps) => {
  const newChildren = flattenChildren(children).map((child) => {
    if (isValidElement(child)) {
      if (child.type === 'li') {
        return cloneElement(child);
      }
      return <li key={child.key}>{child}</li>;
    }
  });

  return <ul>{newChildren}</ul>;
};

const App = () => (
  <List>
    <li>Apple 🍏</li>
    <>
      <a href="banana.html">Banana 🍌</a>
      <p>Strawberry 🍓</p>
    </>
    <span>Blueberry 🫐</span>
  </List>
);

Komponenty jako dzieci

Przyszła pora na to, co nas najbardziej interesuje, czyli faktyczny komponent jako dziecko List. Tym razem zamiast sprawdzać, czy child.type jest równy typowi li, sprawdzamy, czy jest on taki sam jak komponent.

A co z propsami? Nasz komponent będzie przyjmował wszystkie atrybuty li + ref (typ JSX.IntrinsicElements['li']), a jego dzieci będą miały typ ReactText:

import { ReactElement, isValidElement, ReactText } from 'react';
import flattenChildren from 'react-keyed-flatten-children';

type ListItemProps = {
  children: ReactText;
} & JSX.IntrinsicElements['li'];

const ListItem = (props: ListItemProps) => {
  return <li {...props}></li>;
};

type ListProps = {
  children: Array<ReactElement>;
};

export const List = ({ children }: ListProps) => {
  const newChildren = flattenChildren(children).map((child) => {
    if (isValidElement(child)) {
      if (child.type === ListItem) {
        return <ListItem key={child.key} {...(child.props as ListItemProps)} />;
      } else {
        throw new Error('Tylko ListItem może być dzieckiem List');
      }
    }
  });

  return <ul>{newChildren}</ul>;
};

To co tutaj jest nowe, to wyjątek, rzucamy go, gdy typ nie jest zgodny z ListItem - w ten sposób obchodzimy ograniczenia typowania.

const App = () => (
  <List>
    <ListItem>Apple 🍏</ListItem>
    <>
      <ListItem>Banana 🍌</ListItem>
      <ListItem>Strawberry 🍓</ListItem>
    </>
    <span>Blueberry 🫐</span> // ❌ Error: Tylko ListItem może być dzieckiem List
  </List>
);

Prywatne children

Ostatni z naszych case'ów - naszymi dziećmi będą komponenty + nie będziemy mogli ich użyć poza komponentem rodzica. Zaciekawieni? Jedziemy!

Zanim przejdziemy do typów, stwórzymy dwa komponenty - ListItem i PrivateListItem. Nie przeraź się tym pierwszym, rzuca on wyjątkiem z konkretnego powodu, zaraz do tego wrócimy! PrivateListItem za to, tak jak poprzednio, jest po prostu normalnym komponentem, który zwraca li razem z propsami:

type ListItemProps = {
  children: ReactText;
} & JSX.IntrinsicElements['li'];

const ListItem = (_props: ListItemProps) => {
  throw new Error('ListItem musi być dzieckiem List');
};

const PrivateListItem = (props: ListItemProps) => {
  return <li {...props}></li>;
};

Pozostało nam tylko, tak jak poprzednio, w odpowiedni sposób spłaszczyć i przeiterować przez nasze children w komponencie <List>, z małą różnicą...Tym razem sprawdzamy czy dziecko jest typu ListItem, jeśli tak, to zwracamy PrivateListItem:

import { ReactElement, isValidElement, ReactText } from 'react';
import flattenChildren from 'react-keyed-flatten-children';

type ListItemProps = {
  children: ReactText;
} & JSX.IntrinsicElements['li'];

export const ListItem = (_props: ListItemProps) => {
  throw new Error('ListItem musi być dzieckiem List');
};

const PrivateListItem = (props: ListItemProps) => {
  return <li {...props}></li>;
};

type ListProps = {
  children: Array<ReactElement>;
};

export const List = ({ children }: ListProps) => {
  const newChildren = flattenChildren(children).map((child) => {
    if (isValidElement(child)) {
      if (child.type === ListItem) {
        return <PrivateListItem key={child.key} {...(child.props as ListItemProps)} />;
      } else {
        throw new Error('Tylko ListItem może być dzieckiem List');
      }
    }
  });

  return <ul>{newChildren}</ul>;
};

Ten kod na pierwszy rzut oka wydaje się mało intuicyjny, ale zobaczmy jak wygląda wywołanie:

const App = () => (
  <>
    <List>
      <ListItem>Apple 🍏</ListItem>
      <>
        <ListItem>Banana 🍌</ListItem>
        <ListItem>Strawberry 🍓</ListItem>
      </>
    </List>
    <ListItem>Blueberry 🫐</ListItem> // ❌ Error: ListItem musi być dzieckiem List
  </>
);

Co prawda do komponentu List możemy przekazać wszystko co jest zgodne z typem Array<ReactElement>, ale zostaną wyrenderowane tylko komponenty PrivateListItem! Hmm...więc po co ta sztuczka z ListItem? 🤔 Dzięki niej nie możemy wywołać ListItem nigdzie indziej niż w komponencie List i to są właśnie te "prywatne children"!

Problemy z kompozycją

Podwyższe rozwiązania mają jeden poważny problem - brak możliwości opakowania komponentu <ListItem> w inny komponent. Możemy go niejako rozwiązać deklarując komponent rodzica:

type ListItemProps = {
  children: ReactText;
} & JSX.IntrinsicElements['li'];

type ChildWithParent = ReactElement & {
  type: { parent: ReactElement };
  props: ListItemProps;
};

const ListItem = (_props: ListItemProps) => {
  throw new Error('ListItem musi być dzieckiem List');
};

ListItem.parent = List;

export const List = ({ children }: ListProps) => {
  const newChildren = flattenChildren(children).map((child: unknown) => {
    const child = child as ChildWithParent;
    if (isValidElement(child)) {
      if (child.type === ListItem && child.type.parent === List) {
        return <PrivateListItem key={child.key} {...(child.props as ListItemProps)} />;
      } else {
        throw new Error('Tylko ListItem może być dzieckiem List');
      }
    }
  });

  return <ul>{newChildren}</ul>;
};

const ListItemWrapper = (props: ListItemProps) => <ListItem {...props} />;

ListItemWrapper.parent = List;

Przy takim założeniu, każdy wrapper na komponent <ListItem>, taki jak np. <ListItemWrapper> będzie musiał posiadać sztywno zadeklarowanego rodzica - ListItemWrapper.parent = List.

Podsumowanie

Uff, to by było na tyle jeśli chodzi o children w React & TypeScript. Dzisiejszy wpis potraktuj jako taki pakiet wiedzy o typach dla children oraz zbiór ciekawostek. Większość reaktowych codebasów (i komponentów) nie będzie potrzebowała tego typu restrykcji. Polecam Ci szczególnie zapoznać się z innymi metodami dla React.Children, chociaż ich również nie stosuje się na co dzień, to jak widać, potrafią się przydać :)

Jeśli ten artykuł okazał się dla Ciebie przydatny, podziel się nim z innymi!

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ę