1-Dec

Development, React

Polymorfisme i React: 2 mønstre du må kjenne til

Enten du lager et bibliotek eller lager React-komponenter for din egen del, er det en teknikk du bør kjenne til: polymorfisme. Det er når én ting kan ta flere former, som når en knapp kan opptre som en lenke. Brukt riktig, kan det redde deg fra å vedlikeholde ulike varianter, samtidig som det kan gi brukerne fleksibiliteten de trenger fra komponenten.

I denne teksten vil jeg vise deg hvordan du kan utnytte 2 av de mest kjente måtene å få til polymorfisme i React: “as”- og “asChild”- mønstrene.

5 min read

·

By Marcus Haaland

·

December 1, 2023

Jeg har lagd en nydelig, juleinspirert knapp. Ta en titt!

Juleinspirert knapp
Juleinspirert knapp

Den har en lekker animasjon idet den hviler, og den kommer virkelig til live i det du hovrer den. Implementasjon og bruk ser slik ut:

export function Button(props: any) {
  return (
    <button className="button" {...props} />
  );
}

<Button onClick={() => alert("🎉")}>Open Present!</Button>

Som du ser over, så er det kun en knapp akkurat nå, men jeg ønsker å også kunne bruke den som en lenke.

Hvordan får jeg til det?

as-mønsteret

Å skulle rendre en lenke seende ut som en knapp er en vanlig problemstilling. Dette kan løses med as-mønsteret.

En av måtene å implementere as-mønsteret er å utnytte en helt spesiell egenskap i JSX:

Om et element har stor bokstav, vil React tolke det som en elementtype. Da vil React rendre elementtypen basert på variabelens verdi.

Her bruker vi dette, ved å ta verdien i as-propen og putter det inn i variabelen Tag:

export function Button(props: any) {
  const Tag = props.as || "button";
  return (
    <Tag className="button" {...props} />
  );
}

Så om as har verdien "a", vil elementtypen være en anchor-tag, og ikke en knapp:

<Button onClick={() => alert('🎉')}>Open Present!</Button>

<Button as="a" href="/party">Join Christmas Party</Button>

Det er forresten ikke noe magisk ved å bruke akkurat “Tag”. Også “Component” er et vanlig navn, men du kan bruke hva enn du vil så lenge variabelen er med stor forbokstav.

Resultat ser slik ut. Legg merke til at oppe i venstre hjørnet i tooltipen vises det at den ene knappen nå er av elementtype <a>:

Med as-propen har knappene lik styling, men ulik elementtype
Med as-propen har knappene lik styling, men ulik elementtype

TypeScript med as-mønsteret

Akkurat nå har vi ingen typesikkerhet, så konsumere av komponenten kan sende inn alt mulig rart, inkludert ikke gyldige elementtyper.

For å få til riktig typing av as, kan vi type propen til en generisk type T, og la den arve av ElementType. Det betyr at as-propen nå kan være gyldige elementtyper, som <a> eller <button>:

import React, { ElementType, ComponentPropsWithoutRef } from "react";

type ButtonProps<T extends ElementType> = {
  as?: T;
} & ComponentPropsWithoutRef<T>;

For å få riktig typing av propsene bruker vi ComponentPropsWithoutRef. Vi sender den vår generiske type, så vi får en advarsel om vi prøver å sette ikke-gyldige props for et element, som å sette href ved as="button".

Videre i komponenten, ser implementasjonen slik ut, hvor vi også må si ifra om generisk type til funksjonen:

export function Button<T extends ElementType = "button">(
  props: ButtonProps<T>
) {
  const Tag = props.as || "button";
  
  return <Tag className="button" {...props} />;
}

Vi har nå en knapp implementert med as-mønsteret som tillater gyldige elementtyper, og som vil gi riktig typede props!

Fra as til asChild

as-mønsteret er én måte å få til polymorfisme, hvor du innad i komponenten definerer hvordan andre elementtyper skal håndteres. Et annet polymorfistisk mønster er å bruke asChild, som lar barnet bestemme forelderens elementtype.

Her er LinkButton implementert med hver av mønstrene:

<Button as="a" href="/party">Join Christmas Party</Button>

<Button asChild>
  <a href="/party">
    Join Christmas Party
  </a>
</Button>

Slik asChild fungerer, er å sende props fra forelderkomponenten til barnekomponenten. I dette tilfellet er første barn til knappen en lenke, så det rendres som en lenke.

Etter å ha sett asChild i flere biblioteker, ble jeg overrasket over at dette faktisk ikke er en innebygd React-prop, men noe som må implementeres — i likhet med as-propen.

Implementere asChild

Med asChild-mønsteret ønsker vi å rendre en forelder som barne-elementets elementtype, men samtidig også beholde forelderens props. En enkel implementasjon av asChild kan se slik ut:

export function Button({ asChild, children, ...props }: any) {
  return asChild ? (
    React.cloneElement(children, { className: "button", ...props })
  ) : (
    <button className="button" {...props}>
      {children}
    </button>
  );
}

Hvis asChild ikke er valgt, rendrer vi den vanlige knappen.

Men når asChild er valgt, benytter vi en funksjon du kanskje ikke bruker hver dag: kloning. Med React.cloneElement kloner vi barne-elementet, med dens elementtype og alle dens verdier. Andre-argumentet er mulige andre egenskaper det klonede elementet har, og her putter vi forelderens props. På denne måten arver <a>-tagen Button sin stil og oppførsel, men bevarer barnet sin elementtype.

Denne enkle implementasjonen får frem poenget, men den håndterer ikke situasjoner som stil- og prop-kræsj. Biblioteket Radix har ekstrahert ut denne logikken i en komponent de kaller Slot.

Det er altså denne implementasjonen jeg vil anbefale om du skal støtte asChild:

import { Slot } from "@radix-ui/react-slot";

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

export function Button({ asChild, ...props }: ButtonProps) {
  const Tag = asChild ? Slot : "button";
  return <Tag className="button" {...props} />;
}

Når skal jeg bruke as og når skal jeg bruke asChild?

Når det kommer til å velge mellom as og asChild, kan det være uklart hvilken du bør gå for, siden begge tilnærmingene kan oppnå lignende resultater.

Når det konkret kommer til LinkButton, har jeg en preferanse:

  1. Tydelig Rollefordeling: Med asChild blir skillet mellom hvilket element som håndterer styling og funksjonalitet tydeligere. Button styrer stilen, mens <a>-tagen tar seg av navigasjonsaspektet.
  2. Fleksibilitet: asChild gir fleksibilitet til å endre barneelementet, som å bytte fra en <a>-tag til en <span>, uten å miste stylingen som Button tilbyr.
  3. Unngå Kompleksitet: Denne tilnærmingen unngår kompleksiteten som kan oppstå ved å måtte støtte flere elementtyper direkte i Button. Med as-tilnærmingen er det fort gjort å skulle støtte for mange utgaver, som gjør koden uleselig.

Det er imidlertid situasjoner hvor asChild ikke er mulig, som med en Heading-komponent som skal støtte flere overskriftsnivåer (h1, h2, h3, osv.). Her gir det mening å endre elementtypen direkte med as-propen.

Det er også et spørsmål om du i det hele tatt skal bruke polymorfisme. Når du ser at ting ligner på hverandre, kan det gi mening å kombinere dem med polymorfisme. Men om du har mange særtilfeller avhengig av elementtype, er det enklere å holde dem separate.

Avsluttende ord

Det finnes mange måter å lage en LinkButton på, og to av dem er med as-prop eller asChild. Det er et tveegget sverd, siden polymorfisme kan også gjøre koden mer komplisert. Men brukt riktig, er det verktøy som kan gjøre koden din mer leselig og vedlikeholdbar.

Om du er nysgjerrig på hva som ligger bak Slot-implementasjonen, har Jacob Paris en mer detaljrik forklaring her: https://www.jacobparis.com/content/react-as-child

Om du vil enda mer i dybden av polymorfisme og generics i TypeScript, ta en titt på Emmanuel Ohans guide her: https://www.freecodecamp.org/news/build-strongly-typed-polymorphic-components-with-react-and-typescript/