Sytuacja kobiet w IT w 2024 roku
7.07.20206 min
Łukasz Kowalczyk

Łukasz KowalczykJunior Software Developer

Czy naprawdę potrzebujesz biblioteki do zarządzania stanem aplikacji w React?

Sprawdź, jak podejść do asynchronicznych metod zwracających dane stanu w React i czy naprawdę potrzebujesz biblioteki do zarządzania stanem aplikacji w React.

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 providerem contextu, dzięki czemu będą one miały dostęp do globalnego stanu aplikacji
  • LoginForm, komponent zawierający prosty formularz logowania i używający contextu do aktualizowania globalnego stanu
  • Header, komponent wyświetlający rezultat działania komponentu LoginForm i pobierający aktualny stan aplikacji z contextu


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 [statedispatch] 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ą. ?

<p>Loading...</p>