Kategoria:TypeScript

TypeScript - React, Redux i Styled Components

  • Czas potrzebny na przeczytanie:12 minut
  • Opublikowane:

Zgłosiłem 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.

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ę:

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.

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:

Hooki

useState

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

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:

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:

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:

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.

  • 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:

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

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ę