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:
- TypeScript - podstawowe typy, funkcje, tablice i interfejsy
- TypeScript - Generics, klasy i zaawansowane typy.
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
ipropTypes
. - 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:
- Zastosować const assertion.
- Zwrócić wartość jako tuple array.
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!