TypeScript - React, Redux i Styled Components

Cześć 👋 Frontlive się rozwija, a wraz z nim moje umiejętności. Ostatnio za namową Mateusza, Devmentora dodałem możliwość komentowania postów. Jeśli masz jakieś wątpliwości, czegoś nie zrozumiałeś lub po prostu chcesz pogadać, pisz śmiało!

Zgłosiłem również tego bloga do serwisu zbierającego blogi/vlogi o front-endzie - Polski Front-End. Polecam, na pewno traficie tam na ciekawy content, dzięki Bartek za dodanie!

Dziś przyjrzymy się trochę bliżej mojemu ulubionemu połączeniu, czyli React + TypeScript 💙. Zahaczymy też o Reduxa i Styled Components, na pewno nie pożałujesz, zaczynajmy!

Co powinieneś wiedzieć?

  • Powinieneś swobodnie poruszać się po Reakcie
  • Znać podstawy TypeScriptu

Jeśli TS jest dla Ciebie nowością, to zachęcam Cię najpierw do przeczytania dwóch poprzednich wpisów o TypeScripcie:

Agenda

Instalacja

Zacznijmy od najważniejszego, instalacji. Żeby nie tracić czasu na ustawianie całego projektu od zera, skorzystajmy z Create React App wraz z TypeScriptiowym templatem.

npx create-react-app my-app --template typescript

Komponenty

Klasowe

Developerzy odchodzą powoli od komponentów klasowych w Reacie, ale warto zawsze mieć szersze spojrzenie na świat Reacta. To samo tyczy się typowania, więc jak będzie wyglądał nasz komponent klasowy w połączeniu z TypeScriptem?

W połączeniu z TypeScriptem, React.Component jest typem generycznym i przyjmuję taką formę:

class App extends React.Component<Propsy, State> {...}

Spójrzmy na przykładzie:

type MyProps = {
  name: string;
  id: number;
};

type MyState = {
  age: number;
};

class App extends React.Component<MyProps, MyState> {
  state: MyState = {
    age: 20,
  };

  render() {
    const { name, id } = this.props;
    const { age } = this.state;
    return (
      <>
        <span>User name: {name}</span>
        <span>User id: {id}</span>
        <span>User age: {age}</span>
      </>
    );
  }
}

export default App;

Zamiast type możesz również używać interfejsów, wspominałem o ich różnicach w poprzednim wpisie.

Funkcyjne

Teraz coś, co Reactowcy lubią najbardziej, czyli komponenty funkcyjne.

Mogą być one otypowane jak normalna funkcja:

type User = {
  name: string;
  age: number;
  isMarried: boolean;
};

const UserProfile = ({ name, age, isMarried }: User) => {...}

Na pewno niektórzy z was mogli się spotkać z czymś takim jak React.FC lub React.FunctionalComponent.

React.FC w dużym uproszczeniu to po prostu skrót od React.FunctionalComponent.

const UserProfile: React.FC<{ name: string; age: number }> = (name, age) => {...}

Co daje nam zastosowanie React.FC?

  • Zapewnia typy dla statycznych wartości takich jak defaultProps i propTypes.
  • Zapewnia definicję typów dla children.

Z React.FC i defaultProps wiążą się pewne problemy, warto mieć to na uwadze.

Wykorzystanie z React.FC i type:

const SomeProvider: React.FC = ({ children }) => <div>{children}</div>;

Hooki

useState

TypeScript nie jest głupi i w wielu przypadkach sam się domyśli, jaki powinien być typ.

const [isVisible, setVisibility] = useState(false);

Często jednak się zdarza, że nasz state może być np. null lub object. W takim przypadku musimy zadeklarować typy, robimy to za pomocą nawiasów - < >. Taka konstrukcja może Ci się kojarzyć z typami generycznymi.

type User = {
  name: string;
  age: number;
};

const [user, setUser] = useState<User | null>(null);

useEffect

W tym przypadku nie musimy się martwić typami, zadbajmy tylko o to, żeby zwracać funkcję lub undefined.

type User = {
  name: string;
  id: number;
};

const UsersList = () => {
  const [users, setUsers] = useState<User[] | null>(null);

  useEffect(() => {
    const fetchUsers = async () => {
      const apiKey = `https://usersapi/all`;
      const getUsers = await fetch(apiKey);
      const usersData = await getUsers.json();
      setUsers(usersData);
    };
    fetchUsers();
  }, []);
};

useRef

Tutaj podobna sytuacja jak w useState. Podajemy typ elementu i nulla. Mamy tutaj jednak dwie opcje:

  • Tylko do odczytu: const ref = useRef<HTMLInputElement>(null!).
  • Mutowalny, możemy go zmieniać: const ref = useRef<HTMLInputElement | null>(null).
const HappyInput = () => {
  const ref = React.useRef<HTMLInputElement | null>(null);

  const handleFocus = () => {
    // sprawdzamy czy current istnieje
    if (ref.current) {
      ref.current.focus();
    }
  };
  return (
    <div>
      <label>Focus ME!</label>
      <input ref={ref} placeholder="Happy input" />
      <button onClick={handleFocus}>Click to focus :)</button>
    </div>
  );
};

useReducer

Sprawdźmy jak przerobić przykład licznika z dokumentacji Reacta na TypeScripta.

Po kolei, definiujemy typ State, który wykorzystujemy zarówno w reducerze, jak i w początkowym stanie. Przy reducerach fajnie sprawdzają się enumy.

Action to tzw. Discriminated Unions.

interface State {
  count: number;
}

enum Types {
  INCREMENT = "INCREMENT",
  DECREMENT = "DECREMENT",
}

type Action = { type: Types.INCREMENT } | { type: Types.DECREMENT };

const reducer = (state: State, action: Action) => {
  const { INCREMENT, DECREMENT } = Types;
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

const initialState: State = { count: 0 };

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { INCREMENT, DECREMENT } = Types;
  return (
    <>
      Counter: {state.count}
      <button onClick={() => dispatch({ type: DECREMENT })}>-</button>
      <button onClick={() => dispatch({ type: INCREMENT })}>+</button>
    </>
  );
};

Custom hooks

Własne hooki są super! Jeśli jeszcze nie stworzyłeś swojego własnego hooka, to zachęcam, jeżeli jednak masz już to za sobą, to sprawdź jak możesz połączyć własne hooki i TypeScripta.

// useToggle.tsx

import { useState } from "react";

const useVisibility = () => {
  const [isVisible, setVisibility] = useState(false);

  const toggleVisibility = () => setVisibility((prevState) => !prevState);

  return [isVisible, toggleVisability];
};

export default useVisibility;

// App.tsx

const App = () => {
  const [isVisible, toggleVisibility] = useToggle();
  return (
    <>
      <button onClick={toggleVisibility}>Toggle me!</button>
      {isVisible ? (
        <span aria-label="wave hand" role="img">
          👋
        </span>
      ) : null}
    </>
  );
};

Wszystko wydaję się działać prawidłowo, niestety mamy tutaj błąd w onClick. Z hooka zwracamy union type, co w naszym przypadku jest niechcianym zachowaniem. Możemy to zmienić na dwa sposoby:

Najlepszym sposobem będzie opcja numer dwa, z const assertion wiążą się pewne problemy.

// useToggle.tsx

import { useState } from "react";

const useVisibility = () => {
  const [isVisible, setVisibility] = useState(false);

  const toggleVisibility = () => setVisibility((prevState) => !prevState);

  return [isVisible, toggleVisibility] as [boolean, () => void];
};

export default useVisibility;

Formularze i zdarzenia

React zapewnia swój system zdarzeń. Zobaczmy na podstawowy event MouseEvent.

const App = () => {
  const handleClick = (event: React.MouseEvent) => {
    console.log(event.target);
  };

  return <button onClick={handleClick}>click</button>;
};

MouseEvent to tylko jeden z wielu eventów, z tych popularniejszych można na pewno wspomnieć o ChangeEvent.

Możemy również nadawać restrykcje typów dla konkretnego eventu, powiedzmy, że handleClick powinno być tylko dla przycisków.

const App = () => {
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    console.log(event.target);
  };

  return <button onClick={handleClick}>click</button>;
};

Union types

Typy generyczne wspierają union types, nic nie stoi na przeszkodzie, żebyśmy taki zdefiniowali:

const handleClick = (
  e:
    | React.MouseEvent<HTMLButtonElement, MouseEvent>
    | React.FormEvent<HTMLFormElement>
) => {
  console.log(e.target);
};

SyntheticEvent

Syntetyczne zdarzenia to w dużym uproszczeniu wszystkie, więc jeśli nie znajdujesz zdarzenia (np. onInput), możesz użyć SyntheticEvent.

const handleSubmit = (e: React.SyntheticEvent) => {
  e.preventDefault();
  const target = e.target as typeof e.target & {
    email: { value: string };
    password: { value: string };
  };
};

<form ref={formRef} onSubmit={handleSubmit}>
  <div>
    <label>Email:</label>
    <input type="email" name="email" />
  </div>
  <div>
    <label>Password:</label>
    <input type="password" name="password" />
  </div>
</form>;

Context

Wykorzystajmy nasz poprzedni przykład z reducerem do stworzenia contextu. W tym wypadku pomijamy defaultową wartość dla contextu, jest to sposób z użyciem takich ala hooksów zaprezentowanych przez Kent C. Doddsa, dzięki takiej metodzie nie musimy za każdym razem sprawdzać, czy context !== undefined. Drugim znanym mi sposobem jest użycie funkcji pomocniczej createCtx, o której więcej możesz przeczytać tutaj.

interface State {
  count: number;
}

enum Types {
  INCREMENT = "INCREMENT",
  DECREMENT = "DECREMENT",
}

type Action = { type: Types.INCREMENT } | { type: Types.DECREMENT };

type Dispatch = (action: Action) => void;

type CountProviderProps = { children: React.ReactNode };

const CountStateContext = React.createContext<State | undefined>(undefined);

const CountDispatchContext = React.createContext<Dispatch | undefined>(
  undefined
);

const reducer = (state: State, action: Action) => {
  const { INCREMENT, DECREMENT } = Types;
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

const CountProvider = ({ children }: CountProviderProps) => {
  const [state, dispatch] = React.useReducer(countReducer, { count: 0 });
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
};

const useCountState = () => {
  const context = React.useContext(CountStateContext);
  if (context === undefined) {
    throw new Error("useCountState must be used within a CountProvider");
  }
  return context;
};

const useCountDispatch = () => {
  const context = React.useContext(CountDispatchContext);
  if (context === undefined) {
    throw new Error("useCountDispatch must be used within a CountProvider");
  }
  return context;
};

export { CountProvider, useCountState, useCountDispatch };

Portale

Przeróbmy przykład z Reactowych docsów na TypeScripta. Wykorzystujemy tutaj asercje typów, reszta nie powinna być dla Ciebie nowością.

const Modal: React.FC = ({ children }) => {
  const modalRoot = document.getElementById("modal-root") as HTMLElement;
  const el: HTMLElement = document.createElement("div");

  useEffect(() => {
    modalRoot.appendChild(el);

    return () => modalRoot.removeChild(el);
  }, [el, modalRoot]);

  return ReactDOM.createPortal(children, el);
};

HOC

Osobiście nie jestem fanem komponentów wyższego rzędu, chociażby dlatego, że przy większych rozmiarach są one mało czytelne. W dobie IMHO lepszy rozwiązań takich jak render props czy też własnych hooków, HOC to rozwiązanie, z którego najrzadziej korzystam. Polecam Ci to porównanie, żebyś sam określił, co jest dla Ciebie najlepszą opcją.

Przejdźmy do meritum:

function logProps<T>(WrappedComponent: React.ComponentType<T>) {
  return class extends React.Component {
    componentWillReceiveProps(
      nextProps: React.ComponentProps<typeof WrappedComponent>
    ) {
      console.log("Current props: ", this.props);
      console.log("Next props: ", nextProps);
    }
    render() {
      return <WrappedComponent {...(this.props as T)} />;
    }
  };
}

Teraz pewnie pomyślisz, ale to jest nieczytelne!! Ostrzegałem 😄. Okej, ale co tu się stało? Nie będę omawiał całej logiki komponentu, bo jest to przykład z dokumentacji, który możesz znaleźć tutaj. A co jeśli chodzi o typy? Mamy tutaj funkcję generyczną, której parametrem jest WrappedComponent, jest on również typu generycznego. Okej, to jest jasne a co z tą dziwną konstrukcją? ...(this.props as T)? Jest to spowodowane znanym już od wersji 3.2 problemem. Więcej możesz dowiedzieć się w tym issue.

Redux

W tej części zajmiemy się Reduxem wraz z biblioteką React Redux.

Instalacja definicji typów

Zacznijmy do zainstalowania definicji typów:

npm install -D @types/react-redux

Akcje

Zamiast action constants znalazłem zastosowanie dla enumów. Definiujemy tutaj enuma UserTypes, który będzie nam jeszcze potrzebny za chwilę, przy reducerach. Jest jeszcze interface UserActionTypes i alias Name. Całość spinamy w naszą akcję:

enum UserTypes {
  GET_NAME = "GET_NAME",
}

interface UserActionTypes {
  type: UserTypes.GET_NAME;
  payload: string;
}

type Name = string;

export function getUserName(name: Name): UserActionTypes {
  return {
    type: SEND_MESSAGE,
    payload: name,
  };
}

Reducery

Importujemy tutaj wcześniej przygotowane typy, następnie definiujemy interface UserState, który później podajemy jako typ dla stanu początkowego. Niżej mamy już tylko reducer i typ zwracanej wartości oraz stanu.

import { UserTypes, UserActionTypes } from "./types";

interface UserState {
  userName: string;
}

const initialState: UserState = {
  userName: "",
};

const { GET_NAME } = UserTypes;

const userReducer = ( state = initialState, action: UserActionTypes): UserState => {
  switch (action.type) {
    case : GET_NAME
      return {
        ...state
        userName: action.payload,
      };
    default:
      return state;
  }
}

useSelector

Okej, przechodzimy do React Redux.

Najlepszym sposobem, według mnie, na otypowanie useSelecotra jest sposób z useTypedSelector.

import { useSelector, TypedUseSelectorHook } from "react-redux";

interface RootState {
  isVisible: boolean;
}

export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;

Importujemy useSelector i TypedUseSelectorHook, tworzymy zmienną, a właściwie nowego, otypowane hooka, który przyjmuje typ generyczny TypedUseSelectorHook. Podajemy do niego typ stanu początkowego i gotowe!

Wykorzystanie:

const isVisible = useTypedSelector((state) => state.isVisible);

useDispatch

Warto zapamiętać, że defaultowym typem dla dispatch jest Dispatch, nie musimy tutaj typować niczego, no chyba, że chcemy customowego dispatcha.

// Store
export type AppDispatch = typeof store.dispatch;

// Zastosowanie w komponencie
const dispatch: AppDispatch = useDispatch();

Thunk

Redux Thunk to jeden z najpopularniejszych middlewarów do Reduxa. W Thunku mamy dostęp do typu ThunkAction, jak wygląda on z definicji?

export type ThunkAction<R, S, E, A extends Action> = (
  dispatch: ThunkDispatch<S, E, A>,
  getState: () => S,
  extraArgument: E
) => R;

Całość wydaje się mocno przytłaczająca przez wszechobecne typy generyczne.

Uprośćmy sobie powyższy przykład:

type ThunkAction<generics> = (dispatch, getState, extraArgument) => ReturnType;

Co oznaczają R, S, E i A?

  • R: typ zwracany
  • S: typ początkowego stanu i zwracanego z getState()
  • E: dodatkowe argumenty
  • A: typ akcji

Na początku warto zdefiniować sobie aliasa typu, sama konstrukcja jest mało czytelna, więc zapiszmy ją tylko raz.

export type AppThunk = ThunkAction<void, RootState, null, Action<string>>;
  • R: void
  • S: RootState
  • E: null
  • A: Action

Wykorzystanie w akcji:

export const fetchUser = (id: string): AppThunk => async (dispatch) => {
  try {
    // sukces
  } catch (err) {
    // niepowodzenie
  }
};

Styled Components

Zacznijmy od zainstalowania definicji typów:

npm install @types/styled-components

Theme

Na początek, stwórzmy sobie plik styled.d.ts z deklaracją typów. Deklarujemy teraz moduł styled-components a w nim interface DefaultTheme.

import "styled-components";

declare module "styled-components" {
  export interface DefaultTheme {
    primaryColor: string;
    secondaryColor: string;
  }
}

DefaultTheme na początku jest pusty, dlatego musimy go rozszerzyć.

Utwórzmy teraz nasz theme:

import { DefaultTheme } from "styled-components";

const myTheme: DefaultTheme = {
  primaryColor: "#FF5733",
  secondaryColor: "#8A1800",
};

export { myTheme };

Propsy

Najczęściej jednak w SC korzystamy z propsów, spójrzmy na przykładzie:

const StyledHeading = styled.h2<{ customColor: string }>`
  color: ${(props) => props.customColor};
`;

Podajemy tutaj typ propsa w object type literal.

Podsumowanie

Dzięki za wytrwanie do końca! Poniżej znajdziesz wszystkie źródła, z których korzystałem tworząc ten wpis. Szczególnie polecam Ci tego cheatsheeta, jeśli chcesz dowiedzieć się jeszcze więcej.

Pamiętaj, jeśli nie jesteś pewien jakiegoś typu, zawsze możesz najechać na daną rzecz i się przekonać, są jeszcze definicję typów, w których znajdziesz prawdopodobnie odpowiedź na pytanie: Ale jakiego to jest typu?

Polecam Ci przećwiczyć całość na własnym projekcie, w ten sposób najlepiej utrwalisz zdobytą wiedzę.

Do usłyszenia!

Źródła