Funkcyjne komponenty React

W moim ostatnim poście przedstawiłem podstawy Reacta, a dzisiaj pójdziemy krok dalej i przyjrzymy się jak rozdzielić logikę zmiany stanu komponentu od logiki renderowania, wykorzystując przy tym elementy programowania funkcyjnego.

Zacznijmy od tego, że odsyłamy komponenty klasowe do lamusa. Każdy z naszych komponentów będzie funkcją

(props: ComponentProps) => ReactNode

W moim przykładzie będziemy mieli pole tekstowe do wpisania danych oraz przycisk do ich wysłania.

// Example.tsx
export function Example(props: {}) {
    return (
        <div>
            <input />
            <button>Send</button>
        </div>
    );
}

Zastanówmy się jak będzie wyglądał stan takiego komponentu:

  • data: string - wartość pola danych, które jest kontrolowane
  • sending: boolean - informacja, czy dane są właśnie wysyłane, podczas której musimy poczekać

W osobnym pliku utworzę funkcję, która będzie ten stan nam dostarczać

// Example.state.ts
export function useExampleState(): ExampleState {
    // TODO
}

type ExampleState = {
    data: string;
    sending: boolean;
}

Wcześniej było tak, że to komponent zarządzał swoim stanem. Teraz się to nieco zmienia, bo komponent dostanie ograniczone pole do działania. Zamiast zwracać tylko stan, nasza funkcja będzie również zwracać modyfikatory stanu.

export function useExampleState(): [ExampleState, ExampleActions] { }

type ExampleActions = {
    setData(newData: string) => void;
    sendData() => void;
}

Mamy interfejs do zmiany danych (w wyniku zmiany pola tekstowego) oraz mamy funkcję, którą należy zawołać kiedy chcemy wysłać dane - po naciśnięciu przycisku.

Zmodyfikujemy teraz nasz komponent, żeby to uwzględnić

export function Example(props: {}) {
    const [state, actions] = useExampleState();

    function handleChange(event: { target: { value: string } }) {
        actions.setData(event.target.value);
    }

    return (
        <div>
            <input value={state.data} onChange={handleChange} />
            {
                state.sending
                    ? <button disabled>Sending...</button>
                    : <button onClick={actions.sendData}>Send</button>
            }
        </div>
    );
}

Zostało nam tylko napisać implementację useExampleState. Skorzystamy tutaj z React hooks o nazwie useState, który w parametrze dostaje stan początkowy.

export function useExampleState(): [ExampleState, ExampleActions] {
    const [data, setData] = useState("");
    const [sending, setSending] = useState(false);

    async function sendData() {
        setSending(true);
        
        // do work
        await new Promise(resolve => setTimeout(resolve, 500));

        setSending(false);
    }

    return [{data, sending}, {setData, sendData}];
}

W ten sposób ukrywamy detale implementacji stanu przed komponentem i udostępniamy tylko to co istotne.

W celu przetestowania tego systemu, najlepiej byłoby wyciągnąć funkcję sendData poza funkcję useExampleState i przekazywać jej setSending w argumencie, korzystając z częściowej aplikacji przy przekazaniu jej do komponentu.

DI i testowanie
export async function sendData(setSending: (t: boolean) => void, data: string) {
    setSending(true);

    // do work
    await new Promise(resolve => setTimeout(resolve, 500));

    setSending(false);
}

Taką funkcję możemy zwrócić opakowując ją w lambdę:

const appSendData = () => sendData(setSending, data);

Możemy ją też przetestować

it("sendData makes proper calls", async () => {
    const mockSetSending = jest.fn();
    const dataValue = "test";

    await sendData(mockSetSending, dataValue);

    expect(mockSetSending).toHaveBeenNthCalledWith(1, true);
    expect(mockSetSending).toHaveBeenNthCalledWith(2, false);
});

Jeśli chcielibyśmy przetestować czy useExampleState poprawnie dokonuje zwrotu danych i aplikacji funkcji, to musimy zmockować moduł Reacta.

import { useState } from "react";

jest.mock("react");

W ten sposób możemy określić zachowanie useState przy użyciu

const mockUseState = useState as jest.Mock;
mockUseState.mockImplementation(() => ...);

Co istotne tutaj, to to, że mockImplementation trzeba oddzielnie wykonać dla każdego it (a przynajmniej tak mi wychodziło). Więc można się wspomóc jakąś funkcją inicjalizującą, którą będziemy wołać w każdym naszym teście.