W jednym z moich poprzednich artykułów opisałem hook useReducer jako doskonały sposób zarządzania stanem aplikacji React, w tym aplikacji połączonych z API GraphQL za pomocą klienta Apollo.
Zazwyczaj w aplikacji masz stan zdalny (z serwera, z API – mam na myśli GraphQL API), ale możesz też mieć stan lokalny, który nie istnieje po stronie serwera.
Powiedziałem, że useReducer nadaje się do zarządzania takimi sytuacjami – co więcej – za pomocą Apollo Client istnieje inny sposób zarządzania stanem lokalnym.
Apollo Client i State Management
Klient Apollo 3 umożliwia zarządzanie stanem lokalnym poprzez field policies i reactive variables. Field Policy pozwala określić, co się stanie, jeśli zapytasz o określone pola, w tym te, które nie zostały zdefiniowane po stronie serwera GraphQL. Zasady pól definiują pola lokalne, dzięki czemu pola są wypełniane informacjami przechowywanymi w dowolnym miejscu, takimi jak lokalna pamięć masowa i zmienne reaktywne.
Tak więc Apollo Client (wersja >=3) zapewnia dwa mechanizmy obsługi stanu lokalnego:
- Local-only fields and field policies
- Reactive variables
Klient Apollo łączy React App i GraphQL API. Ponadto jest biblioteką do zarządzania stanem.
Pomaga połączyć aplikację React z GraphQL API. Dostarcza metody komunikacji z API, mechanizmami cache, helperami itp.
Poza tym klient Apollo zapewnia zintegrowany system zarządzania stanem, który pozwala zarządzać stanem całej aplikacji.
Co to jest Apollo State?
Apollo Client ma swój system zarządzania stanem wykorzystujący GraphQL do bezpośredniej komunikacji z zewnętrznymi serwerami i zapewnia skalowalność.
Apollo Client obsługuje zarządzanie lokalnym i zdalnym stanem aplikacji, dzięki czemu będziesz mógł wchodzić w interakcje z dowolnym stanem na dowolnym urządzeniu z tym samym interfejsem API.
Local-only fields i field policies
Ten mechanizm umożliwia utworzenie schematu klienta. Możesz rozszerzyć schemat serwera lub dodać nowe pola.
Następnie możesz zdefiniować field policies, które opisują, skąd pochodzą dane. Możesz użyć pamięci podręcznej Apollo lub pamięci lokalnej.
Kluczową zaletą tego mechanizmu jest korzystanie z tego samego API, co przy pracy ze schematem serwera.
Local state: przykład
Jeśli chcesz obsługiwać dane lokalne w standardowym zapytaniu GraphQL, musisz użyć dyrektywy @client dla pól lokalnych:
query getMissions ($limit: Int!){
missions(limit: $limit) {
id
name
twitter
website
wikipedia
links @client // this field is local
}
}
Definicja local state przy użyciu local-only fields
Apollo InMemory cache
Klient Apollo zapewnia system buforowania danych lokalnych. Znormalizowane dane są zapisywane w pamięci, dzięki czemu dane już zapisane w pamięci podręcznej mogą być szybko pobierane.
Field type policies
Możesz odczytywać i zapisywać w pamięci podręcznej klienta Apollo. Ponadto możesz dostosować sposób obsługi określonego pola w pamięci podręcznej. Możesz określić funkcje odczytu, zapisu i scalania oraz dodać logikę niestandardową.
Aby zdefiniować stan lokalny, należy:
- Zdefiniować zasady pola i przekaż je do InMemoryCache
- Dodać zdefiniowane wcześniej pole do query używając dyrektywy @client
Local-only fields tutorial
Wejdźmy głębiej w pole tylko lokalne i sprawdźmy, jak działają w akcji.
Zainicjuj projekt za pomocą aplikacji Create React
$ npx create-react-app local-only-fields
Zainstaluj apollo client
$ npm install @apollo/client graphql
Zainicjuj Apollo client
Import apollo client w index.js:
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
} from "@apollo/client";
Utwórz instancję klienta:
const client = new ApolloClient({
uri: 'https://api.spacex.land/graphql/',
cache: new InMemoryCache()
});;
API.spacex.land/graphql to fantastyczna bezpłatna publiczna wersja demonstracyjna GraphQL API, więc używam jej tutaj. Jeśli chcesz eksplorować ten interfejs API, skopiuj adres URL do przeglądarki: https://api.spacex.land/graphql/
Połącz Apollo z Reactem, opakowując komponent App w ApolloProvider:
<ApolloProvider client={client}>
<App />
</ApolloProvider>
ApolloProvider przyjmuje argument klienta, którym jest nasz już zadeklarowany klient Apollo. Dzięki temu możemy korzystać z funkcji Apollo Client w komponencie App i każdym komponencie potomnym.
Zapytanie po dane misji
Pobierzmy dane z API. Chcę pobrać misje:
query getMissions ($limit: Int!){
missions(limit: $limit) {
id
name
twitter
website
wikipedia
}
}
Wyniki dla tego zapytania, gdy przekazałem 3 jako zmienną graniczną:
{
"data": {
"missions": [
{
"id": "9D1B7E0",
"name": "Thaicom",
"twitter": "https://twitter.com/thaicomplc",
"website": "http://www.thaicom.net/en/satellites/overview",
"wikipedia": "https://en.wikipedia.org/wiki/Thaicom"
},
{
"id": "F4F83DE",
"name": "Telstar",
"twitter": null,
"website": "https://www.telesat.com/",
"wikipedia": "https://en.wikipedia.org/wiki/Telesat"
},
{
"id": "F3364BF",
"name": "Iridium NEXT",
"twitter": "https://twitter.com/IridiumBoss?lang=en",
"website": "https://www.iridiumnext.com/",
"wikipedia": "https://en.wikipedia.org/wiki/Iridium_satellite_constellation"
}
]
}
}
Stwórzmy komponent React, który odbiera te dane i na razie wyświetla nazwę Misji na ekranie.
Najpierw utwórz test jednostkowy: src/components/Missions/tests/Missions.spec.js
import { render } from "@testing-library/react"
import Missions from '../Missions';
describe('Missions component', () => {
it('Should display name of each mission', () => {
const { getByText } = render(<Missions/>);
getByText('Missions component should be here.')
})
})
Oczywiście test kończy się niepowodzeniem, ponieważ nie mamy jeszcze utworzonego komponentu.
Dodaj komponent: src/components/Missions/Missions.js
import React from "react"
export const Missions = () => {
return <div>
Missions component should be here.
</div>
}
export default Missions;
Teraz tst przechodzi:
Re-exportuj komponent w src/components/Missions/index.js
export { default } from './Missions';
Musimy zapytać o dane za pomocą haka useQuery dostarczonego przez klienta Apollo.
W testach jednostkowych musisz mieć komponent opakowany przez ApolloProvider. Do celów testowych Apollo zapewnia unikalnego dostawcę: MockedProvider i umożliwia dodanie fałszywych danych. Użyjmy tego.
// src/components/Missions/__tests__/Missions.spec.js
import MockedProvider:
import { MockedProvider } from '@apollo/client/testing';
Zdefiniuj mocki:
const mocks = [
{
request: {
query: GET_MISSIONS,
variables: {
limit: 3,
},
},
result: {
"data": {
"missions": [
{
"id": "9D1B7E0",
"name": "Thaicom",
"twitter": "https://twitter.com/thaicomplc",
"website": "http://www.thaicom.net/en/satellites/overview",
"wikipedia": "https://en.wikipedia.org/wiki/Thaicom"
},
{
"id": "F4F83DE",
"name": "Telstar",
"twitter": null,
"website": "https://www.telesat.com/",
"wikipedia": "https://en.wikipedia.org/wiki/Telesat"
},
{
"id": "F3364BF",
"name": "Iridium NEXT",
"twitter": "https://twitter.com/IridiumBoss?lang=en",
"website": "https://www.iridiumnext.com/",
"wikipedia": "https://en.wikipedia.org/wiki/Iridium_satellite_constellation"
}
]
}
}
},
];
Test kończy się niepowodzeniem, ponieważ nie mamy jeszcze zdefiniowanego zapytania GET_MISSIONS.
Utwórz plik query/missions.gql.js:
import { gql } from '@apollo/client';
export const GET_MISSIONS = gql`
query getMissions ($limit: Int!){
missions(limit: $limit) {
id
name
twitter
website
wikipedia
}
}
`;
Zaimportuj query w src/components/Missions/__tests__/Missions.spec.js
import { GET_MISSIONS } from "../../../queries/missions.gql";
Teraz otocz komponent Missions dostawcą Mocked.
const { getByText } = render(
<MockedProvider mocks={mocks}>
<Missions/>
</MockedProvider>
);
Teraz możemy spodziewać się, że trzy misje produktowe będą widoczne na ekranie, ponieważ w naszej próbnej odpowiedzi mamy tablicę z trzema misjami o odpowiednich nazwach: „Thaicom”, „Telstar” i „Iridium NEXT”.
W tym celu zaktualizuj przypadek testowy.
Najpierw ustaw przypadek testowy jako asynchroniczny, dodając słowo kluczowe async przed funkcją wywołania zwrotnego it.
Po drugie, zastąp zapytanie getByText zapytaniem findByText, które działa asynchronicznie.
it('Should display name of each mission', async () => {
const { findByText } = render(
<MockedProvider mocks={mocks}>
<Missions/>
</MockedProvider>
);
await findByText('Thaicom');
await findByText('Telstar');
await findByText('Iridium NEXT');
});
Test kończy się niepowodzeniem, ponieważ nie pytamy o dane w komponencie React.
Przy okazji, może myślisz, że nie owijam findBytext przez expect…toBe. Nie robię tego, ponieważ zapytanie findByText zgłasza błąd, gdy nie może znaleźć podanego tekstu jako argumentu, więc nie muszę tworzyć asercji, ponieważ test zakończy się niepowodzeniem, jeśli tekst nie zostanie znaleziony.
Zaktualizujmy komponent React.
Najpierw zaimportuj hak useQuery i zapytanie GET_MISSIONS w src/components/Missions/Missions.js
import { useQuery } from "@apollo/client";
import { GET_MISSIONS } from "../../queries/missions.gql";
Zapytajmy o dane w komponencie:
const { data } = useQuery(GET_MISSIONS, {
variables: {
limit: 3
}
});
Teraz przygotujmy treść, którą komponent wyrenderuje dla nas. Jeśli istnieją misje, pozwól wyświetlić nazwę każdej misji. W przeciwnym razie pokażmy akapit „Nie ma misji”.
const shouldDisplayMissions = useMemo(() => {
if (data?.missions?.length) {
return data.missions.map(mission => {
return <div key={mission.id}>
<h2>{mission.name}</h2>
</div>
})
}
return <h2>There are no missions</h2>
}, [data]);
Komponent musi zwrócić shouldDisplayMissions:
import React, { useMemo } from "react"
import { useQuery } from "@apollo/client";
import { GET_MISSIONS } from "../../queries/missions.gql";
export const Missions = () => {
const { data } = useQuery(GET_MISSIONS, {
variables: {
limit: 3
}
});
const shouldDisplayMissions = useMemo(() => {
if (data?.missions?.length) {
return data.missions.map(mission => {
return <div key={mission.id}>
<h2>{mission.name}</h2>
</div>
})
}
return <h2>There are no missions</h2>
}, [data]);
return shouldDisplayMissions;
}
export default Missions;
Teraz test przechodzi!
Ostatnią rzeczą w tym kroku jest wstrzyknięcie komponentów do aplikacji i zobaczenie misji w przeglądarce.
// App.js
import Missions from './components/Missions';
function App() {
return <Missions/>;
}
export default App;
Działa, ale początkowo pokazuje komunikat „Brak misji”. Naprawmy to, dodając wskaźnik loading w pliku Missions.js.
Najpierw pobierz flagę loading z wyników haka useQuery:
const { data, loading } = useQuery(GET_MISSIONS, {
variables: {
limit: 3
}
});
Dodaj loading indicator:
if (loading) {
return <p>Loading...</p>
}
return shouldDisplayMissions;
Poza tym dodaj trochę stylizacji.
//src/components/Missions/Missions.module.css
.mission {
border-bottom: 1px solid black;
padding: 15px;
}
Teraz zaimportuj moduł CSS w pliku Missions.js:
import classes from './Missions.module.css'
i dodaj klasę misji do div misji:
return <div key={mission.id} className={classes.mission}>
<h2>{mission.name}</h2>
</div>
Rezultat naszego działania:
Dodawanie local-only field
OK, więc mamy dane z API. Kolejnym zadaniem jest wyświetlenie linków do Misji. API zwraca trzy pola:
- website
- Wikipedia
Możemy stworzyć nasze lokalne pole o nazwie: links. Będzie to tablica z łączami, więc możemy przejść przez tę tablicę i po prostu wyświetlić łącza.
Najpierw dodajmy nowy przypadek testowy:
it ('Should display links for the mission', async () => {
const localMocks = [
{
...mocks[0],
result: {
data: {
missions: [
{
"id": "F4F83DE",
"name": "Telstar",
"links": ['https://www.telesat.com/']
},
]
}
}
}
]
const { findByText } = render(
<MockedProvider mocks={localMocks}>
<Missions/>
</MockedProvider>
);
await findByText('https://www.telesat.com/"')
});
Spodziewamy się więc, że zostanie wyrenderowany jeden link: „https://www.telesat.com/„
Definiowanie field policy
Po pierwsze, musimy zdefiniować politykę pola dla naszego pola linków lokalnych.
Gdy sprawdzisz dokumentację doczyczącą zapytania misji w GraphQL API, zobaczysz, że swraca ono tablicę z obiektami Mission.
Musimy więc dodać pole lokalne o nazwie links do typu misji.
Aby to zrobić, musimy dodać konfigurację do InMeMoryCache w pliku src/index.js w następujący sposób:
const client = new ApolloClient({
uri: 'https://api.spacex.land/graphql/',
cache: new InMemoryCache({
typePolicies: {
Mission: {
fields: {
links: {
read(_, { readField }) {
// logic will be added here in the next step
}
}
}
}
}
})
Teraz zwróćmy tablicę z linkami zebranymi z misji. Funkcja read ma dwa argumenty. Pierwsza z nich to aktualnie przechowywana w pamięci podręcznej wartość pola, jeśli taka istnieje. Drugi to obiekt, który udostępnia kilka właściwości i funkcji pomocniczych. Użyjemy funkcji readField do odczytania innych danych pola.
Nasza logika dla pola lokalnego linków:
read(_, { readField }) {
const twitter = readField('twiiter');
const wikipedia = readField('wikipedia');
const website = readField('website');
const links = [];
if (twitter) {
links.push(twitter);
}
if (wikipedia) {
links.push(wikipedia);
}
if (website) {
links.push(website);
}
return links;
}
Zapytanie po local-only field
Kolejnym krokiem jest uwzględnienie pola linków w zapytaniu. Zmodyfikujmy zapytanie GET_MISSIONS:
query getMissions ($limit: Int!){
missions(limit: $limit) {
id
name
twitter
website
wikipedia
links @client
}
}
Możesz zdefiniować pole tylko lokalne, dodając dyrektywę @client po nazwie pola.
Wyświetlanie local-only field na ekranie
Poczyniliśmy duże postępy, ale test nadal kończy się niepowodzeniem, ponieważ komponent nie renderuje jeszcze żadnych linków.
Zaktualizuj komponent Missions, modyfikując funkcję shouldDisplayMissions Memo.
const shouldDisplayMissions = useMemo(() => {
if (data?.missions?.length) {
return data.missions.map(mission => {
const shouldDisplayLinks = mission.links?.length ? mission.links.map(link => {
return <li key={`${mission.id}-${link}`}>
<a href={link}>{link}</a>
</li>
}) : null;
return <div key={mission.id} className={classes.mission}>
<h2>{mission.name}</h2>
{shouldDisplayLinks}
</div>
})
}
return <h2>There are no missions</h2>
}, [data]);
Jestest super! Wszystko działa równie dobrze w przeglądarce:
testy przechodzą:
DziałająceDemo
Here you can see the demo of the app:
https://apollo-client-local-only-fields-tutorial.vercel.app/
Kod źródłowy
Tutaj możesz znaleźć kod źródłowy tego samouczka: https://github.com/Frodigo/apollo-client-local-only-fields-tutorial
A tu commity dla każdego kroku:
- Initialize the project using Create React App
- Install Apollo Client
- Initialize Apollo Client
- The query for missions data
- Add local-only field
Reactive variables
OK, poznałeś pola tylko lokalne, a teraz spójrzmy na inny mechanizm o nazwie: Zmienne reaktywne.
Możesz zapisywać i odczytywać dane w dowolnym miejscu w swojej aplikacji przy użyciu zmiennych reaktywnych.
Apollo nie przechowuje reaktywnych zmiennych w swojej pamięci podręcznej, więc nie musisz utrzymywać ścisłej struktury danych w pamięci podręcznej.
Klient Apollo wykrywa zmiany w zmiennych reaktywnych, a gdy zmienia się wartość, zmienna jest automatycznie aktualizowana we wszystkich miejscach.
Reactive variables w akcji
Tym razem chciałbym pokazać przypadek z wykorzystaniem zmiennych reaktywnych. Nie chcę powtarzać dokumentów Apollo, więc możesz zobaczyć podstawy czytania i modyfikowania zmiennych reaktywnych tutaj.
Przypadek użycia
W Magento GraphQL API istnieje mutacja createEmptyCart. Ta mutacja zwraca identyfikator wózka.
Rozpocząłem pracę nad wózkiem i miniwózkiem w mojej aplikacji React-Apollo-Storefront. Pierwszą rzeczą, którą musiałem zrobić, było utworzenie pustego koszyka.
Chciałem uzyskać identyfikator koszyka, zapisać go w mojej aplikacji, a po odświeżeniu strony sprawdzić, czy wartość istnieje w stanie lokalnym, a jeśli tak, pobrać ją z niego bez uruchamiania mutacji.
Implementacje
Po pierwsze, zdefiniuj zmienną reaktywną:
import { makeVar } from "@apollo/client";
export const CART_ID_IDENTIFER = 'currentCardId'
export const cartId = makeVar(localStorage.getItem(CART_ID_IDENTIFER));
Po drugie, użyj tej zmiennej w komponencie, kontekście lub hookui spraw, aby była reaktywna:
import { useReactiveVar } from '@apollo/client';
export const CartProvider = ({ children }) => {
const currentCartId = useReactiveVar(cartId);
};
po trzecie, zdefiniuj mutację, aby pobrać identyfikator koszyka z serwera:
// mutation:
import { gql } from '@apollo/client';
export const CREATE_EMPTY_CART = gql`
mutation createEmptyCart {
createEmptyCart
}
`;
// component
// imports
import { useMutation } from '@apollo/client';
import { CREATE_EMPTY_CART } from '../../mutations/cart.gql'
// ... in component body
const [ createEmptyCart ] = useMutation(CREATE_EMPTY_CART, {
update(_, { data: { createEmptyCart } }) {
cartId(createEmptyCart);
localStorage.setItem(CART_ID_IDENTIFER, createEmptyCart);
}
});
użyłem tam wywołania zwrotnego update i zaktualizowałem zmienną reaktywną:
cartId(createEmptyCart)
Następnie zaktualizowałem również wartość w pamięci lokalnej.
Na koniec sprawdzam, czy identyfikator wózka istnieje w stanie lokalnym, a jeśli nie, wyślij mutację do serwera.
const currentCartId = useReactiveVar(cartId);
useEffect(() => {
if (!currentCartId) {
createEmptyCart();
}
}, [createEmptyCart, currentCartId]);
Podsumowanie
Dzisiaj pokazałem Cidwie techniki zarządzania lokalnymi danymi w Apollo. Pola tylko lokalne i zmienne reaktywne. Mechanizmy te zapewniają dużą elastyczność i należy je wziąć pod uwagę podczas projektowania architektury zarządzania stanem w aplikacji React. Dodatkowo polecam poczytać o wyszydzaniu zapytań i mutacji GraphQL.