Czy naprawdę potrzebujesz biblioteki do zarządzania stanem aplikacji w React?
Wybór biblioteki? To zależy od wielu czynników: od zespołu, od klienta, a czasem od samego projektu. Można wybierać z najpopularniejszych (Mobx/Redux), można też zostać hipsterem i wybrać coś, o czym nikt nie słyszał. Ale zespół musi coś wybrać.
No właśnie, czy na pewno musi?
A co, jeśli nie trzeba wybierać?
Od wersji 16.3.0 React został wyposażony w narzędzia umożliwiające zarządzanie globalnym stanem aplikacji bez użycia dodatkowych bibliotek. Narzędzia, o których mowa to nic innego jak React
Hooks i dlatego też prezentowany kod oparty jest na komponentach funkcyjnych, a ponieważ nie wyobrażam sobie pracy bez silnego typowania używam również Typescripta.
NOTKA: Kod, którym prezentuję wykorzystanie wspomnianych wyżej narzędzi, dotyczy prostego formularza logowania. Aby maksymalnie uprościć przykład i ułatwić jego zrozumienie nie dodawałem żadnej walidacji i nie pisałem żadnych serwisów wysyłających zapytania do API. Na temat stanu i zapytań asynchronicznych dodałem osobny akapit na końcu wpisu.
Prezentowana aplikacja będzie się składała z kilku komponentów:
App
, główny komponent ‘opakowujący’ pozostałe provideremcontextu
, dzięki czemu będą one miały dostęp do globalnego stanu aplikacjiLoginForm
, komponent zawierający prosty formularz logowania i używający contextu do aktualizowania globalnego stanuHeader
, komponent wyświetlający rezultat działania komponentuLoginForm
i pobierający aktualny stan aplikacji zcontextu
Proces tworzenia aplikacji dla uproszczenia został podzielony na 6 kolejnych kroków:
Krok 1 – model
Wiadomo – najpierw model, w końcu trzeba mieć na czym pracować ? a skoro przykład ma być prosty to i model nie będzie skomplikowany:
export interface IUser {
name: string;
isLoggedin: boolean;
}
Krok 2 – akcje reducera
W prezentowanym przykładzie używany jest hook useReducer
, a nie useState
(choć też jest to możliwe), a zatem potrzebna będzie funkcja reducera. Zanim jednak ona powstanie trzeba zdefiniować jakie typy akcji będzie obsługiwała:
import { IUser } from "../../models/IUser";
export type LOG_IN = { actionType: "LOG_IN", payload: IUser };
export type LOG_OUT = { actionType: "LOG_OUT", payload: IUser };
export type UserActionType = LOG_IN | LOG_OUT;
Jak widać funkcja reducera będzie obsługiwała akcje logowania i wylogowania użytkownika, a razem z typem podjętej akcji otrzyma dodatkowo jego zaktualizowane dane (payload
).
Krok 3 – funkcja reducera
Po zdefiniowaniu akcji można przystąpić do zdefiniowania funkcji reducera:
import { UserActionType } from "./actionTypes";
import { IUser } from "../../models/IUser";
export function userReducer(state: IUser, action: UserActionType): IUser {
switch (action.actionType) {
case 'LOG_IN':
return { ...action.payload, isLoggedin: true };
case 'LOG_OUT':
return { ...action.payload, isLoggedin: false };
default:
return state;
}
}
Zaprezentowana w powyższym przykładzie funkcja została oparta o switch
sprawdzający typ podjętej akcji. Zgodnie z tym typem podejmuje właściwe działania: aktualizuje stan użytkownika na zalogowanego lub na niezalogowanego.
Wspomniany wcześniej hook useReducer
oprócz już utworzonej funkcji reducera będzie również wymagał przekazania stanu początkowego:
import { IUser } from "../models/IUser";
export const USER_INITIAL_STATE: IUser = {
name: '',
isLoggedin: false
}
Po takim przygotowaniu można śmiało używać(do zarządzania stanem komponentu) hooka useReducer
tak to jest prezentowane w dokumentacji:
const [state, dispatch] = React.useReducer(userReducer, USER_INITIAL_STATE);
Para [state
, dispatch
] to tuple
typu:
[IUser, React.Dispatch<UserActionType>]
Jest to bardzo ważne w odniesienu do następnego kroku, w którym utworzony zostaje context wykorzystujący hook useReducer
.
Krok 4 – context
„Kontekst umożliwia przekazywanie danych wewnątrz drzewa komponentów bez konieczności przekazywania ich przez właściwości każdego komponentu pośredniego.” Tak jak mówi definicja z oficjalnej strony Reacta – kontekst umożliwia dostęp do przechowywanych w nim danych globalnie. Zatem jeśli zostanie umieszczony w nim hook do zarządzania stanem będzie można zarządzać tym stanem globalnie!
W poprzednich krokach przygotowany został hook useReducer
, który będzie przechowywany w kontekście. Teraz należy przygotować kontekst i użyć go do zarządzania stanem całej aplikacji. Kontekst tworzony jest przy użyciu funkcji createContext
:
import * as React from 'react';
import { IUser } from '../../models/IUser';
import { UserActionType } from '../../reducers/userReducer/actionTypes';
import { USER_INITIAL_STATE } from '../../statics/USER_INITIAL_STATE';
export const UserContext = React.createContext<[IUser, React.Dispatch<UserActionType>]>
([
USER_INITIAL_STATE,
() => {
throw new Error("Method not implemented. Please use context provider")
}
]);
Jak widać typem danych przechowywanych w tworzonym kontekście jest ten sam tuple, który zwraca nam hook useReducer
:
[IUser, React.Dispatch<UserActionType>]
Do funkcji createContext
przekazane zostały również domyślne wartości, które odczytane zostaną tylko w przypadku odczytu danych z kontekstu bez użycia providera.
Teraz kiedy został utworzony kontekst, trzeba zaimplementować providera odpowiedzialnego za dostarczenie danych z kontekstu.
Krok 5 – context provider
Providera używa się jak normalnego komponentu React’a i tak też się go implementuje. Prezentowany Provider będzie posiadał swoje właściwości:
import { IUser } from "../../models/IUser";
import { UserActionType } from "../../reducers/userReducer/actionTypes";
import { ReactNode } from "react";
export interface IUserContextProviderProps {
reducer: (state: IUser, event: UserActionType) => IUser;
initialState: IUser;
children: ReactNode;
}
Provider będzie wymagał przekazania funkcji reducera , stanu początkowego aplikacji oraz komponentów, które będą miały dostęp zarówno do globalnego stanu aplikacji jak i metod aktualizujących ten stan.
Po zdefiniowaniu właściwości providera można przejść do jego implementacji:
import * as React from 'react';
import { UserContext } from './userContext';
import { IUserContextProviderProps } from './IUserContextProviderProps';
export const UserContextProvider = (props: IUserContextProviderProps) =>
<UserContext.Provider value={React.useReducer(props.reducer, props.initialState)}>
{props.children}
</UserContext.Provider>
export const useUserState = () => React.useContext(UserContext);
Właściwość value
z powyższego przykładu jest dokładnie tą wartością, która będzie dostępna globalnie w całej aplikacji i tak jak wspomniano wcześniej będzie nią useReducer
.
W ostatniej linijce zaimplementowany zostaje własny hook: useUserState
umożliwiający dostęp do danych z kontekstu w komponentach funkcyjnych.
Tak zaimplementowany kontekst jest już gotowy do zarządzania globalnym stanem aplikacji.
Krok 6 – użycie
Tak jak wspomniano na początku aplikacja składa się z trzech komponentów, a poniżej znajduje się ich implementacja:
App:
App:
import React from 'react';
import { userReducer } from './reducers/userReducer/userReducer';
import './App.css';
import { LoginForm } from './components/loginForm/LoginForm';
import { UserContextProvider } from './contexts/userContext/UserContextProvider';
import { Header } from './components/header/Header';
import { USER_INITIAL_STATE } from './statics/USER_INITIAL_STATE';
function App() {
return (
<UserContextProvider initialState={USER_INITIAL_STATE} reducer={userReducer}>
<Header />
<LoginForm />
</UserContextProvider>
);
}
export default App;
Komponent ten odpowiedzialny jest za użycie przekazania do providera wymaganych właściwości oraz za renderowanie pozostałych komponentów owiniętych providerem, dzięki czemu będą miały dostęp do stanu aplikacji.
LoginForm:
import React from 'react'
import { useUserState } from '../../contexts/userContext/UserContextProvider';
export const LoginForm = () => {
const [userState, dispatch] = useUserState();
const [userNameState, setUserName] = React.useState(userState.name);
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
dispatch({ actionType: 'LOG_IN', payload: { ...userState, name: userNameState } });
}
const handleUserNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
}
return (
<form onSubmit={handleFormSubmit} className="loginForm">
<label>User name:</label>
<input type="text" name="name" onChange={handleUserNameChange} />
<button type="submit">Log in</button>
</form>
);
}
W tym komponencie znajduje się prosty formularz służący do logowania użytkownika poprzez wprowadzenie jego imienia i kliknięcie przycisku. Do obsługi danych z formularza(czyli wewnętrznego stanu komponentu) użyty został hook useState.
Najciekawsze jest tutaj użycie własnego hooka useUserState
. Daje on dostęp do stanu aplikacji, a także do metody dispatch
, za której pośrednictwem można ten stan zmieniać.
Header:
import * as React from 'react';
import { UserContext } from './userContext';
import { IUserContextProviderProps } from './IUserContextProviderProps';
export const UserContextProvider = (props: IUserContextProviderProps) =>
<UserContext.Provider value={React.useReducer(props.reducer, props.initialState)}>
{props.children}
</UserContext.Provider>
export const useUserState = () => React.useContext(UserContext);
Komponent Header
również wykorzystuje własny hook useUserState
, jednak nie korzysta z funkcji dispatch
, a jedynie odczytuje dane stanu aplikacji i renderuje informacje o zalogowanym użytkowniku bądź o jego braku.
Słowo o asynchroniczności
Używając w przeszłości Mobx’a pamiętam jak pisało się store’y wyposażone we flagi sygnalizujące czy trwa zapytanie do API czy nie. Służyło to do np. renderowania spinnera w czasie oczekiwania na odpowiedź z serwera, a po uzyskaniu odpowiedzi store był odpowiednio aktualizowany. Podczas nauki na temat contextu React’a i nowych hooków w analogiczny sposób próbowałem zaimplementować reducera i context co sprawiało mi nie lada problem. Jak się okazało słusznie.
Po odwiedzeniu kilkunastu stron i forów internetowych doszedłem do wniosku, że nie był to najlepszy pomysł. W końcu nie do tego służy reducer i context ani nie powinien był (gdyby pisać to po Mobx’owemu) służyć store. Reducer w połączeniu z kontekstem służy do aktualizacji stanu aplikacji, a nie do wykonywania zapytań do API – zarządzanie stanem powinno być jego jedyną odpowiedzialnością.
Jak zatem podejść do asynchronicznych metod zwracających dane stanu? Wg mnie nic nie stoi na przeszkodzie aby komponenty korzystające z serwisów po uzyskaniu odpowiedzi przekazywały właściwe dane do reducera i w ten sposób aktualizowały stan aplikacji. Tym sposobem kontekst i reducer będą odpowiedzialne wyłącznie za aktualizację stanu aplikacji, serwisy za komunikację z API, a komponenty za renderowanie właściwego contentu. Czytelnie i zgodnie ze sztuką. ?