Kategorie
Transkrypcje YouTube

Jak typować React Context API? #TypeScript

🔴 Type safety w React Context? Musiałem troszkę pogłówkować zanim znalazłem dla mnie najlepszy sposób na otypowanie konteksty z TypeScriptem. W tym odcinku dowiesz się jak wnioskować typy bezpośrednio z custom hooka. Zobaczysz jak poradzić sobie z problemem domyślnej wartości kontekstu, aby nie pisać żadnych assercji lub zaślepek.

Zajmę się problemem, który długo nie dawał mi spokoju, dopóki nie zacząłem na poważnie odkrywać możliwości TypeScripta. Chodzi o problem typu: co było pierwsze, kura czy jajko?

Podczas tworzenia nowego kontekstu React przy współpracy z TypeScriptem prosi o podanie wartości początkowej. Z racji tego, że nic jeszcze nie mam to wrzucam tam np. null. Problem powstaje, gdy coś z kontekstu trzeba pobrać. Wtedy TS cały czas myśli, że może tam być właśnie ta wartość początkowa, czyli u mnie null.

Mogę wrzucić jako wartość początkowa pusty obiekt, użyć assercji i oszukać TS, że to jest pożądany przeze mnie typ. Ale to jest bardzo brzydko!

Zabieramy się do roboty!

Punktem wyjścia jest pusty plik, gdzie chcę utworzyć custom hooka useSettings. Hook ten będzie korzystał z React Context API. Wszystko będzie zamknięte właśnie za fasadą custom hooka, będzie to zaenkapsulowane właśnie w pliku modules/settings.ts. Hook useSettings będzie eksponował obiekt, dzięki któremu będzie można odczytywać i zmieniać ustawienia aplikacji we właściwy sposób. Zawartość tego hooka nie będzie tak istotna jak sam proces, który doprowadził do jego powstania. Wszystko będzie bezpiecznie otypowane, tak żeby nic nie wybuchło w runtime, czyli type safe.

Na początek trzeba utworzyć pusty kontekst

Zrobię to przez funkcję createContext, którą dostarcza React.

import { createContext } from 'react';
const SettingsContext = createContext();

I już teraz TS mi piszczy, że coś mu się nie podoba: An argument for 'defaultValue’ was not provided.

Znaczy to, że TypeScript spodziewa się tutaj wartości początkowej. Nie wiem jeszcze co do dokładnie będzie, więc daję null.

const SettingsContext = createContext(null);

Utworzenie Providera

Teraz tworzę providera, czyli komponent, którym będę oplatać inne komponenty, tak aby miały dostęp do kontekstu. Jest to zwykły komponent funkcyjny zatem mogę mu podać taką sygnaturę. Skorzystam z typu FC, czyli Function Component z paczki Reakta.

const SettingsProvider: FC = () => {}

Co ten provider ma robić? Skoro jest komponentem to powinien zwracać JSX. Użyję tutaj kontekstu, który przed chwilą stworzyłem. Ma on dostępny po kropce, jako pole obiektu, komponent Provider. W środku będzie children przekazane dalej z mojego providera.

A więc piszę:

const SettingsProvider: FC = ({ children }) => {
  return (
    <SettingsContext.Provider>
      {children}
    </SettingsContext.Provider>
  );
}

Aktualnie ten Provider nic nie robi. Do tego TypeScript podkreśla mi coś na czerwono:

Property 'value’ is missing in type '{ children: ReactNode; }’ but required in type 'ProviderProps’.

Do providera muszę przekazać jeszcze wartość kontekstu. TypeScript wywnoskował, że typ wartości kontekstu, tego value, jest null. Dzieję się tak, ponieważ to właśnie null przypisałem jako wartość początkową kontekstu.

Powiedzmy TypeScriptowi co tak właściwie, chcę trzymać w kontekście

Nadeszła pora, aby otypować właściwe wartości kontekstu. Tworzę więc nowy typ: SettingsContextData. Lecz zamiast samemu definiować jak ten kontekst ma wyglądać, pozwolę TS’owi, aby sam to wywnioskował. Do tego celu stworzę kolejną funkcję, będzie to również custom hook: useProvideSettings. To co będzie zwracać ten hook, będzie właśnie moim kontekstem, jego wartością, którą chcę, aby inne komponenty-dzieci miały do niej dostęp. Użyję do tego typu pomocniczego wbudowanego w TypeScript: ReturnType.

const useProvideSettings = () => {}
type SettingsContextData = ReturnType<typeof useProvideSettings>

Aktualnie typem SettingsContextData jest void. Tak wnioskuje to TS. Powinienem coś zwrócić z useProvideSettings. Na potrzeby tego video niech to będzie tylko jakiś stan utworzony za pomocą hooka useState. Będzie to stan theme, może przyjmować wartości dark albo light. Zarówno wartość, jak i setter zwracam z tej funkcji, z tego hooka w postaci plain object.

const useProvideSettings = () => {
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');

  return {
    theme,
    setTheme,
  };
}

type SettingsContextData = ReturnType<typeof useProvideSettings>

Teraz w typie SettingsContextData jest już coś konkretnego, a dokładnie:

type SettingsContextData = {
    theme: "dark" | "light";
    setTheme: Dispatch<SetStateAction<"dark" | "light">>;
}

Teraz ustawiam typ SettingsContextData, jako typ wartości kontekstu:

const SettingsContext = createContext<SettingsContextData>(null);

Natomiast null nie pasuje do typu SettingsContextData, o czym daje mi wyraźnie dać TS. Zatem rozszerzam zbiór możliwych wartości kontekstu o null:

const SettingsContext = createContext<SettingsContextData | null>(null);

Ustawianie wartości kontekstu w providerze

Przyszła pora na użycie hooka useProvideSettings wewnątrz providera. Wywołuję go tuż przed return i wstrzykuję jako prop value:

const SettingsProvider: FC = ({ children }) => {
  const value = useProvideSettings();

  return (
    <SettingsContext.Provider value={value}>
      {children}
    </SettingsContext.Provider>
  );
}

Wygląda na to, że mamy to ograne po mistrzowsku 😉

Jak używać kontekstu?

Moja aplikacja jest osadzona we frameworku Next.js. Plikiem wyjściowym dla strony głównej jest pages/index.tsx. Chcę, aby wszystko, co się w niej znajduje miało dostęp do utworzonego właśnie kontekstu. Aby to umożliwić użyję w niej komponentu SettingsProvider. Muszę go zatem wyeksportować i zaimportować właśnie w index.tsx. Następnie oplatam nim całą jego zawartość, tak że cały kod JSX jest opleciony providerem.

Wewnątrz providera wrzucam nieistniejący jeszcze komponent Content. Za chwilę go utworzę. Tutaj to wszystko.

import { SettingsProvider } from '../modules/settings'
import { Content } from '../components/Content'

export default function Home() {
  return (
    <SettingsProvider>
      <Content />
    </SettingsProvider>
    )
  }

Następnie tworzę nowy komponent components/Content.tsx. Dla celów tego video umieszczam w nim tylko tekst, gdzie będzie wyświetlona aktualna wartość theme z utworzonego kontekstu. Muszę pobrać tu kontekst, co uczynię za chwilę.

export const Content = () => {
  return (
    <p>theme: {settingsContext.theme}</p>
  );
}

Wartości z kontekstu mogę pobrać używając hooka useContext. Jako argument tego hooka przekazuję SettingsContext zaimportowany z pliku modules/settings.tsx.

import { useContext } from "react";
import { SettingsContext } from "../modules/settings";

export const Content = () => {
  const settingsContext = useContext(SettingsContext);

  return (
    <p>theme: {settingsContext.theme}</p>
  );
}

I tu pojawia się problem, ponieważ TS widzi, że kontekst może być typu null. Sam to tak zapisałem, ponieważ React wymaga wartości domyślnej, którą z czystego pragmatyzmu ustawiam właśnie na null. Musiałbym zatem sprawdzić, czy ten kontekst, aby na pewno tym nullem nie jest.

Jednak, ja dobrze wiem, że nim nie jest bo zapewnia mi to SettingsProvider. Na obronę TSa i Reakta muszę przyznać, że przecież komponent Content nie ma żadnej pewności, czy zostaje wywołany wewnątrz SettingsProvider. Mogę sprawdzić czy kontekst nie jest nullem, ale czy chcę tak robić za każdym razem kiedy będę pobierał kontekst? Nie.

Warto też zauważyć, że musiałem zaimportować dwie rzeczy: sam hook useContext oraz SettingsContext. Może lepiej stworzyć własny hook useSettings i importować tylko jego? Za pewne tak.

Dodatkowo wewnątrz tego useSettings mógłbym tak wszystko rozegrać, aby sprawdzanie czy wartość kontekstu jest nullem nie była wymagana za każdym razem, gdy chcę tego kontekstu użyć. Do dzieła!

Właściwe typowanie hooka useSettings

Wracam do pliku modules/settings.tsx. Tworzę w nim hooka useSettings, w którym zwracam pobrany kontekst. Ładnie widać, że aktualnie kontekst zwraca SettingsContextData | null. Zatem muszę jeszcze tylko sprawdzić, czy nie jest on nullem. Dodaję ifa i co dalej? Co zrobić jeśli miałby faktycznie tym nullem być? W dodatku jak poinformować TypeScripta, że typ zwracany to tylko SettingsContextData?

Zauważ, że gdy wchodzę do bloku warunkowego, to wewnątrz niego settings jest nullem, o czym wie też TS. Można się o tym przekonać sprawdzając typ zmiennej settings umieszczonej właśnie tam.

export const useSettings = () => {
  const settings = useContext(SettingsContext);

  if (!settings) {
    // settings -> null
  }

  // settings -> SettingsContextData | null
  return settings;
}

Natomiast zaraz za blokiem warunkowym zmienna settings dalej może być nullem. To dlatego, że blok warunkowy nie przerywa działania funkcji. Dzięki temu ifowi rozgałęziamy strukturę kodu rozważając dwie możliwości. Ale nawet gdy wchodzę na gałąź 'z nullem’ to nic z tym nie robię, przez co kod będzie się wykonywał dalej już poza tym blokiem warunkowym.

Pierwsze co przychodzi do głowy, to zwrócenie czegoś w tym bloku, aby przerwać działanie funkcji i nie dopuścić wyjście z nullem poza blok ifa. Ale to będzie bez sensu. Kompletnie nic to nie zmienia! Hook dalej będzie robił to samo. A TS dalej będzie widział, że useSettings może zwrócić null lub jakieś inne śmieci.

Rozwiązanie problemu z typowaniem kontekstu

TypeScript działa tak, że rozgałęziając kod instrukcjami warunkowymi pozwalamy mu weryfikować poszczególne scenariusze. I tak oto mogę zrobić coś gdy wartość jest nullem lub nim nie jest. TypeScript to wyłapie. Ale jak mu powiedzieć, że null jest tutaj kompletnie niepożądany?

Skoro jest niepożądany, a ja jako programista nawet jestem o tym przekonany, że nulla tutaj nie będzie, to warto wyrzucić wyjątek na wypadek, gdyby null jednak był. W końcu mogę niechcący próbować wywołać useSettings nie znajdując się w jego providerze. Jest to ewidentny błąd! Zatem wyrzućmy wyjątek!

export const useSettings = () => {
  const settings = useContext(SettingsContext);

  if (!settings) {
    throw new Error('useSettings must be used inside SettingsProvider');
  }

  return settings;
}

Zobacz co teraz widzi TS, jaki typ zwraca useSettings! Już nie jest to null. Tak jakby TypeScript czytał mi w myślach! Może się tak wydawać, ale on po prostu jest tak zaprojektowany.

Teraz mogę użyć useSettings tak, jak tego chciałem od początku:

import { useSettings } from "../modules/settings";

export const Content = () => {
  const settingsContext = useSettings();

  return (
    <p>theme: {settingsContext.theme}</p>
  );
}

Kiedy wyrzucam błąd informuję TS, że taka sytuacja w ogóle nie powinna mieć miejsca. Skoro to się nie wydarzy, że TS może 'wyciąć’ ten scenariusz podczas wnioskowania typu zwracanego przez funkcję. Pamiętaj jednak, że to na Tobie drogi programisto, droga programistko spoczywa odpowiedzialność, jak taki błąd obsłużyć. W tym przypadku, można przyjąć, że wyrzucenie błędu ma na celu właśnie odjęcie ze zbioru wartości zwracanych nulla. Błąd powinien zobaczyć tylko programista, aby przekonać się, że zapomniał o providerze.