Apollo Client
Apollo Client - это библиотека для управления состоянием
React-приложений
, позволяющая управлять как локальными, так и удаленными д анными с помощьюGraphQL
. Она может использоваться для получения, кеширования и модификации данных приложения с автоматическим обновлением пользовательского интерфейса.
Введение
Ядром Apollo Client
является библиотека @apollo/client
, предоставляющая встроенную поддержку для React
. Также имеются другие реализации.
Почему Apollo Client
?
Декларативное получение данных
Декларативный подход к получению данных состоит в инкапсуляции логики получения данных, отслеживания состояния загрузки и ошибок, а также обновления UI
с помощью хука useQuery()
. Эта инкапсуляция сильно упрощает интеграцию результатов выполнения запросов с презентационными компонентами.
function Feed() {
const { loading, error, data } = useQuery(GET_DOGS)
if (error) return <Error />
if (loading || !data) return <Loader />
return <DogList dogs={data.dogs} />
}
В число продвинутых возможностей, предоставляемых useQuery()
, кроме прочего, входит оптимистическое обновление UI
, автоматическое выполнение повторных запросов и пагинация.
Автоматическое кеширование
Одной из ключевых особенностей Apollo Client
является встроенный нормализованный кеш.
import { ApolloClient, InMemoryCache } from '@apollo/client'
const client = new ApolloClient({
cache: new InMemoryCache()
})
Нормализация позволяет обеспечивать согласованность данных при их использовании в нескольких компонентах. Рассмотрим пример:
const GET_ALL_DOGS = gql`
query GetAllDogs {
dogs {
id
breed
displayImage
}
}
`
const UPDATE_DISPLAY_IMAGE = gql`
mutation UpdateDisplayImage($id: String!, $displayImage: String!) {
updateDisplayImage(id: $id, displayImage: $displayImage) {
id
displayImage
}
}
`
Запрос GET_ALL_DOGS
получает всех собак и их изображения (displayImage
). Мутация UPDATE_DISPLAY_IMAGE
обновляет изображение определе нной собаки. При обновлении изображения определенной собаки, соответствующий элемент списка также должен быть обновлен. Apollo Client
выделяет каждый объект из результата с __typename
и свойством id
в отдельную сущность в кеше. Это гарантирует, что при возврате значения из мутации, каждый запрос на получение объекта с этим id
будет автоматически обновлен. Это также гарантирует, что два запроса, возвращающие одинаковые данные, всегда будут синхронизированы между собой.
Интерфейс политики кеширования (cache policy API) позволяет повторно использовать данные, которые запрашивались ранее. Запрос на получение определенной собаки может выглядеть так:
const GET_DOG = gql`
query GetDog {
dog(id: 'abc') {
id
breed
displayImage
}
}
`
Для того, чтобы в ответ на этот запрос возвращались данные из кеша, необходимо определить кастомную FieldPolicy
(политику поля):
import { ApolloClient, InMemoryCache } from '@apollo/client'
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
dog(_, { args, toReference }) {
return toReference({
__typename: 'Dog',
id: args.id
})
}
}
}
}
})
const client = new ApolloClient({ cache })
Комбинация локальных и удаленных данных
Управление данными с помощью Apollo Client
позволяет использовать GraphQL
в качестве унифицированного интерфейса для всех данных. Это позволяет инспектировать локальные и удаленные (имеется ввиду лежащие на сервере) схемы в Apollo Client Devtools
через GraphiQL
.
const GET_DOG = gql`
query GetDogByBreed($breed: String!) {
dog(breed: $breed) {
images {
id
url
isLiked @client
}
}
}
`
В приведенном примере мы запрашиваем клиентское поле isLiked
вместе с серверными данными.
Начало работы
1. Настройка
Создаем локальный проект с помощью Create React App
или песочницу на CodeSandbox
.
Устанавливаем необходимые зависимости:
yarn add @apollo/client graphql
# или
npm i ...
@apollo/client
- пакет, содержащий все необходимое для настройкиApolloClient
, включая кеш, хранящийся в памяти, управление локальным состоянием, обработку ошибок и основанный наReact
слой представленияgraphql
- утилита для разбора (парсинг а) GrapqQL-запросов
2. Инициализация ApolloClient
Создаем экзмпляр ApolloClient
.
Импортируем из @apollo/client
необходимые инструменты в index.js
:
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
gql,
} from '@apollo/client'
Инициализируем ApolloClient
, передавая в конструктор объект с полями uri
и cache
:
const client = new ApolloClient({
uri: 'https://48p1r2roz4.sse.codesandbox.io',
cache: new InMemoryCache()
})
uri
определяет адрес GrapQL-сервераcache
- это экземплярInMemoryCache
, который используется для кеширования запросов
Наш клиент готов к отправке запросов. В index.js
вызываем client.query()
со строкой запроса, обернутой в шаблонную строку gql
:
// const client = ...
client
.query({
query: gql`
query GetRates {
rates(currency: 'USD') {
currency
}
}
`
})
.then((result) => console.log(result))
Запустите код, откройте консоль инструментов разработчика и изучите объект с результатами. Вы должны увидеть свойство data
с rates
внутри, а также другие свойства, такие как loading
и networkStatus
.
3. Подключение клиента к React
Apollo Client
подключается к React
с помощью ApolloProvider
, который помещает клиента в контекст, чтобы сделать его доступным в любом месте (на любом уровне) дерева компонентов.
import React from 'react'
import { render } from 'react-dom'
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
gql,
} from '@apollo/client'
const client = new ApolloClient({
uri: 'https://48p1r2roz4.sse.codesandbox.io',
cache: new InMemoryCache()
})
function App() {
return (
<div>
<h2>Мое первое Apollo-приложение 🚀</h2>
</div>
)
}
const rootEl = document.getElementById('root')
render(
<ApolloProvider>
<App />
</ApolloProvider>,
rootEl
)
4. Получение данных с помощью хука useQuery()
В index.js
определяем запрос с помощью gql
:
const EXCHANGE_RATES = gql`
query GetExchangeRates {
rates(currency: 'USD') {
currency,
rate
}
}
`
Создаем компонент ExchangeRates
, в котором выполняется запрос GetExchangeRates
с помощью хука useQuery()
:
function ExchangeRates() {
const { loading, error, data } = useQuery(EXCHANGE_RATES)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return data.rates.map(({ currency, rate }) => (
<div key={currency}>
<p>
{currency}: {rate}
</p>
</div>
))
}
При рендеринге этого ком понента useQuery()
автоматически выполняет запрос и возвращает объект, содержащий свойства loading
, error
и data
:
Apollo Client
следит за состоянием загрузки и ошибками, что отражается в свойствахloading
иerror
- результат запроса записывается в свойство
data
Добавляем компонент ExchangeRates
в дерево компонентов:
function App() {
return (
<div>
<h2>Мое первое Apollo-приложение 🚀</h2>
<ExchangeRates />
</div>
)
}
После перезагрузки приложения вы должны увидеть сначала индикатор загрузки, а затем список курсов валют.
Получение данных
Запросы / Queries
Выполнение запроса
Хук useQuery()
- основной API
для выполнения запросов в Apollo-приложениях. Для выполнения запроса в компоненте вызывается useQuery()
, которому передается строка запроса GraphQL
. При рендеринге компонента useQuery()
возвращает объект, содержащий свойства loading
, error
и data
, которые могут использоваться для рендеринга UI
.
Создаем запрос GET_DOGS
:
import { gql, useQuery } from '@apollo/client'
const GET_DOGS = gql`
query GetDogs {
dogs {
id
breed
}
}
`
Создаем компонент Dogs
и передаем GET_DOGS
в useQuery()
:
function Dogs({ onDogSelected }) {
const { loading, error, data } = useQuery(GET_DOGS)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<select onChange={onDogSelected}>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.breed}>
{dog.breed}
</option>
))}
</select>
)
}
UI
рендерится в зависимости от состояния запроса:
- до тех пор, пока
loading
(индикатор выполнения запроса) имеет значениеtrue
, отображается индикатор загрузки - когда
loading
получает значениеfalse
и в процессе выполнения запроса не возникло ошибки (error
), отображается выпадающий список с породами соба к
Когда пользователь выбирает породу, выбранное значение передается родительскому компоненту с помощью колбека onDogSelected()
.
Кеширование результатов запроса
Результат выполненного запроса автоматически записывается в кеш, что делает выполнение последующих аналогичных запросов невероятно быстрым.
Создаем компонент DogPhoto
, принимающий проп breed
, соответствующий текущему значению выпадающего списка в компоненте Dog
:
const GET_DOG_PHOTO = gql`
query Dog($breed: String!) {
dog(breed: $breed) {
id
displayImage
}
}
`
function DogPhoto({ breed }) {
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
variables: { breed }
})
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
}
Обратите внимание, что на этот раз мы, кроме запроса, передаем useQuery()
объект с настройкой variables
- объект с переменными, которые мы хотим передать в запрос. В данном случае мы хотим передать breed
из списка.
Выберите несколько пород и обратите внимание на скорость повторной загрузки изображений. Так работает кеш.
Обновление кешированных результатов
Иногда нам нужно, чтобы результаты запросов оставались актуальными (свежими), т.е. соответствовали данным, хранящимся на сервере. Apollo Client
предоставляет для этого 2 стратегии: polling
и refetching
.
Polling
Polling
(создание пула) обеспечивает синхронизацию с сервером посредством периодического запуска повторного выполнения запроса. Этот режим включается с помощью настройки pollInterval
со значением в мс:
function DogPhoto({ breed }) {
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
variables: { breed },
pollInterval: 500,
})
// ...
}
В приведенном примере запрос на получение изображения собаки будет отправляться 2 раза в секунду. Установка значения pollInterval
в 0
отключает "пулинг".
Пулинг можно запускать динамически с помощью функций startPolling()
и stopPolling()
, возвращаемых useQuery()
.
Refetching
Refetching
(повторное выполнение запроса) позволяет обновлять результаты запроса в ответ на определенное действие пользователя. Добавим в компонент DogPhoto
кнопку, при нажатии на которую будет запускаться функция refetch()
:
function DogPhoto({ breed }) {
const { loading, error, data, refetch } = useQuery(GET_DOG_PHOTO, {
variables: { breed }
})
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<div>
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
<button onClick={() => refetch()}>Отправить повторный запрос</button>
</div>
)
}
Использование refetching
сопряжено к некоторыми трудностями, связанными с отслеживанием состояния загрузки.
Инспектирование состояния загрузки
useQuery()
предоставляет подробную информацию о статусе запроса в свойстве networkStatus
возвращаемого объекта. Для того, чтобы иметь возможность использовать эту информацию, необходимо установить значение настройки notifyOnNetworkStatusChange
в значение true
:
import { NetworkStatus } from '@apollo/client'
function DogPhoto({ breed }) {
const { loading, error, data, refetch, networkStatus } = useQuery(
GET_DOG_PHOTO,
{
variables: { breed },
notifyOnNetworkStatusChange: true
}
)
if (networkStatus === NetworkStatus.refetch)
return <p>Выполнение повторного запроса...</p>
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<div>
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
<button onClick={() => refetch()}>Отправить повторный запрос</button>
</div>
)
}
Установка этой настройки также обеспечивает правильное обновление значения loading
.
Свойство networkStatus
- это перечисление (enum) NetworkStatus
, представляющее различные состояния загрузки. В частности, выполнение повторного запроса представлено NetworkStatus.refetch
.
Инспектирование состояния ошибки
Обработка ошибок может быть кастомизирована с помощью настройки errorPolicy
, которая по умолчанию имеет значение none
. none
означает, что все ошибки расцениваются как ошибки времени выполнения. В этом случае Apollo Client
отбрасывает любые данные, содержащиеся в ответе сервера на запрос, и устанавливает свойство error
объекта, возвращаемого useQuery()
, в значение true
.
Если установить значение errorPolicy
в all
, то результаты запроса отбрасываться не будут, что позволяет рендерить частичный контент.
Ручное выполнение запроса
Для выполнения запросов в ответ на события, отличающиеся от рендеринга компонента, например, в ответ на нажатие пользователем кнопки, используется хук useLazyQuery()
. Он похож на useQuery()
, но вместо выполнения запроса, возвращает функцию для его выполнения.
import React from 'react'
import { useLazyQuery } from '@apollo/client'
function DogPhoto({ breed }) {
const [getDog, { loading, data }] = useLazyQuery(GET_DOG_PHOTO)
if (loading) return <p>Загрузка...</p>
return (
<div>
{data && data.dog && (
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
)}
<button onClick={() => getDog({ variables: { breed } })}>
Нажми на меня
</button>
</div>
)
}
Установка политики выполнения запроса
"Дефолтной" политикой выполнения запроса является cache-first
(сначала кеш). Это означает, что при выполнении запроса проверяется, имеются ли соответствующие данные в кеше. Если такие данные имеются, они возвращаются без отправки запроса на сервер. Если данных нет, отправляется запрос на сервер, данные записываются в кеш и возвращаются.
Для изменения этого поведения используется настройка fetchPolicy
:
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
// только сеть
fetchPolicy: 'network-only',
})
Поддерживаемые политики:
Название | Описание |
---|---|
cache-first | см. выше |
cache-only | Ответ на запрос возвращается только из кеша. При отсутствии кеша, выбрасывается исключение |
cache-and-network | Ответ на запрос возвращается из кеша, после чего отправляется запрос на сервер. Если ответ от сервера отличается от кеша, кеш и результат запроса обновляются. Это позволяет максимально быстро возвращать ответ при сохранении кеша в актуальном состоянии |
network-only | Запрос сразу отправляется на сервер, минуя кеш. При этом, кеш все равно обновляется ответом от сервера |
no-cache | То же самое что network-only , но без обновления кеша |
standby | То же самое что cache-first , но без обновления кеша. В данном случае кеш может обновляться вручную с помощью refetch и updateQueries |
useQuery API
Настройки
Хук useQuery()
в качестве второго аргумента принимает объект со следующими настройками:
Настройки, связанные с выполняемой операцией
query
- строка запросаGraphQL
, которая разбирается в абстрактное синтаксическое дерево с помощью шаблонных литераловgql
. Является опциональной, поскольку запрос может передаваться вuseQuery()
в качестве первого аргументаvariables: { [key: string]: any }
- объект с переменными для запроса. Название ключа соответствует названию переменной, а значение - значению переменнойerrorPolicy
- политика обработки ошибок (см. выше)onCompleted: (data | {}) => void
- колбек, который вызывается при успешном завершении запроса без ошибок (или когдаerrorPolicy
имеет значениеignore
). Функция получает объектdata
с результатами запросаonError: (error) => void
- колбек, который вызывается при провале запроса. Функция получает объектerror
, который может быт ь объектомnetworkError
или массивомgraphQLErrors
в зависимости от типа возникшей ошибкиskip: boolean
- если имеет значениеtrue
, запрос не выполняется (не доступна вuseLazyQuery()
)displayName: string
- название компонента, отображаемое в инструментах разработчикаReact
Настройки, связанные с сетью
pollInterval: number
- определяет периодичность выполнения запроса (в мс)notifyOnNetworkStatusChange: boolean
- если имеет значениеtrue
, изменение сетевого статуса или возникновение сетевой ошибки приводит к повторному рендерингу компонентаcontext
- при использованииApollo Link
данный объект представляет собой начальное значение для объектаcontext
, передаваемого в цепочку ссылок (link chain)ssr: boolean
- если имеет значениеfalse
, выполнение запроса при рендеринге на стороне сервера пропускаетсяclient
- экземплярApolloClient
, который используется для выполнения запроса
Настройки, связанные с кешированием
fetchPolicy
- политика кеширования (см. выше)nextFetchPolicy
- политика кеширования для последующи х запросовreturnPartialData: boolean
- если имеет значениеtrue
, запрос может возвращать из кеша часть данных при отсутствии данных для всех запрошенных полей
Результат
Хук useQuery()
возвращает объект со следующими свойствами:
Данные, связанные с выполняемой операцией
data
- объект с результатами запроса. Может иметь значениеundefined
при возникновении ошибки (зависит от значенияerrorPolicy
)previousData
- объект, содержащий результаты предыдущего запроса. Также может иметь значениеundefined
error
- объект, содержащий либо объектnetworkError
, либо массивgraphQLErrors
variables: { [key: string]: any }
- объект, содержащий переменные, переданные в запрос
Данные, связанные с сетью
loading: boolean
- если имеет значениеtrue
, значит, запрос находится в процессе выполненияnetworkStatus
- число-индикатор текущего состояния запроса. Используется совместно сnotifyOnNetworkStatusChange
client
- экземплярApolloClient
, который используется для выполнения запросаcalled: boolean
- если имеет значениеtrue
, значит, соответствующий "ленивый" (отложенный) запрос выполнен
Вспомогательные функции
refetch: (variables?) => Promise
- функцию, позволяющая повторно выполнять запросы. Может принимать новые переменные. В данном случаеfetchPolicy
по умолчанию имеет значениеnetwork-only
fetchMore: ({ query?, variables?, updateQuery: function }) => Promise
- функция для получения следующего набора результатов для поля с пагинациейstartPolling: (interval) => void
- функция для периодического выполнения запроса (динамически)stopPolling: () => void
- функция для остановки пулингаsubscribeToMore: (options: { document, variables?, updateQuery?: function, onError?: function }) => () => void
- функция для подписки, как правило, на определенные поля, включенные в запросupdateQuery: (previousResult, options: { variables }) => data
- функция для обновления кешированных результатов запроса без выполнения соответствующей операцииGraphQL
Мутации / Mutations
Выполнение мутации
Хук useMutation()
- основной API
для выполнения мутаций в Apollo-приложениях. Для запуска мутации вызывается useMutation()
, которому передается строка GraphQL
, представляющая мутацию. При рендеринге компонента useMutation()
возвращает кортеж, включающий в себя следующее:
- функцию для запуска мутации
- объект с полями, представляющими текущий статус выполнения мутации
Создаем мутацию ADD_TODO
для добавления задачи в список задач:
import { useMutation, gql } from '@apollo/client'
const ADD_TODO = gql`
mutation AddTodo($type: String!) {
addTodo(type: $type) {
id
type
}
}
`
Создаем компонент AddTodo
с формой для отправки задачи в список. Здесь мы передаем ADD_TODO
в useMutation()
:
function AddTodo() {
let input
const [addTodo, { data }] = useMutation(ADD_TODO)
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
addTodo({ variables: { type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Добавить задачу</button>
</form>
</div>
)
}
Запуск мутации
Хук useMutation()
не выполняет мутацию автоматически при рендеринге компонента. Вместо этого, он возвращает кортеж с функцией для запуска мутации на первой позиции (в приведенном примере данная функция присвоена перемен ной addTodo
). Эта функция может вызываться в любой момент. Мы вызываем ее при отправке формы.
Передача настроек
useMutation()
и функция для запуска мутации принимают объекты с настройками. Любая настройка, переданная в мутацию, перезаписывает одноименную настройку, переданную в useMutation()
. В приведенном примере мы указали настройку variables
в addTodo()
, позволяющую передавать переменные для мутации.
Отслеживание статуса мутации
Кроме функции для запуска мутации, useMutation()
возвращает объект, представляющий текущее состояние мутации. Поля этого объекта включают логические значения - индикаторы того, вызывалась ли мутация (called
) или находится ли мутация в процессе выполнения (loading
).
Обновление кеша после мутации
Мутация изменяет данные на сервере. Если эти данные также присутствуют на клиенте, они также должны быть обновлены. Это зависит от того, обновляет ли мутация единичную существующую сущность.
Обновление единичной существующей сущности
В этом случае кеш сущности обновляется автоматически после выполнения мут ации. Для этого мутация должна вернуть id
модифицированной сущности и значения модифицированных полей.
Рассмотрим пример модификации значения любой задачи:
const UPDATE_TODO = gql`
mutation UpdateTodo($id: String!, $type: String!) {
updateTodo(id: $id, type: $type) {
id
type
}
}
`
function Todos() {
const { loading, error, data } = useQuery(GET_TODOS)
const [updateTodo] = useMutation(UPDATE_TODO)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return data.todos.map(({ id, type }) => {
let input
return (
<div key={id}>
<p>{type}</p>
<form
onSubmit={(e) => {
e.preventDefault()
updateTodo({ variables: { id, type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Обновить задачу</button>
</form>
</div>
)
})
}
После выполнения UPDATE_TODO
мутация вернет id
модифицированного элемента списка и его новый type
. Поскольку сущности кешируются по id
, Apollo
знает, какую сущность следует обновить в кеше.
Выполнение других обновлений
Если мутация создает, удаляет или модифицирует несколько сущностей, автоматического обновления кеша не происходит. Для его ручного обновления в useMutation()
может быть включена функция обновления.
Цель функции обновления состоит в обеспечении соответствия кешированных данных с данными, хранящимися на сервере, которые были модифицированы мутацией. В приведенном выше примере функция обновления для мутации ADD_TODO
должна добавлять такую же за дачу в кешированную версию списка.
const GET_TODOS = gql`
query GetTodos {
todos {
id
}
}
`
function AddTodo() {
let input
const [addTodo] = useMutation(ADD_TODO, {
update(cache, { data: { addTodo } }) {
cache.modify({
fields: {
todos(existingTodos = []) {
const newTodoRef = cache.writeFragment({
data: addTodo,
fragment: gql`
fragment NewTodo on Todo {
id
type
}
`,
})
return [...existingTodos, newTodoRef]
},
},
})
},
})
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
addTodo({ variables: { type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Добавить задачу</button>
</form>
</div>
)
}
Функция обновления получает объект cache
, который представляет собой кеш приложения. Этот объект предоставляет доступ к таким методам cache API
, как readQuery
, writeQuery
, readFragment
, writeFragment
и modify
. Данные методы позволяют выполнять операции GrapQL
над кешем так, будто вы взаимодействуете с GraphQL-сервером.
Функция обновления также получает объект со свойством data
, которое содержит результат мутации. Это значение может использоваться для обновления кеша с помощью cache.writeQuery
, cache.writeFragment
или cache.modify
.
Обратите внимание: если мутация содержит оптимистический ответ, функция обновления вызывается дважды: первый раз с оптимистическим ответом, второй - с результатом мутации.
При запуске мутации ADD_TODO
созданный и возвращенный объект задачи записывается в кеш. Однако, кешированный ранее список задач, отслеживаемый запросом GET_TODOS
, не обновляется автоматически. Это означает, что GET_TODOS
не получает уведомления о добавлении новой задачи, что, в свою очередь, означает, что запрос не обновляется и новая задача не отображается. Для исправлени я этой ситуации мы используем cache.modify
, позволяющий добавлять и удалять элементы из кеша путем запуска функции-модификатора. Мы знаем, что результаты запроса GET_TODOS
сохранены в кеше в массиве ROOT_QUERY.todos
, поэтому мы используем функцию-модификатор для обновления этого массива, включая в него ссылку на новую задачу. С помощью cache.writeFragment
мы получаем внутреннюю ссылку на добавленную задачу и сохраняем ее в массиве ROOT_QUERY.todos
.
Любые изменения кешированных данных внутри функции обновления приводят к отправки уведомлений всем заинтересованным в этих данных запросам. Это влечет за собой обновление UI
.
Отслеживание состояния загрузки и ошибок
Перепишем компонент Todos
:
function Todos() {
const { loading: queryLoading, error: queryError, data } = useQuery(GET_TODOS)
const [updateTodo, { loading: mutationLoading, error: mutationError }] =
useMutation(UPDATE_TODO)
if (queryLoading) return <p>Загрузка...</p>
if (queryError) return <p>Ошибка: {queryError.message}</p>
return data.todos.map(({ id, type }) => {
let input
return (
<div key={id}>
<p>{type}</p>
<form
onSubmit={(e) => {
e.preventDefault()
updateTodo({ variables: { id, type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button type='submit'>Обновить задачу</button>
</form>
{mutationLoading && <p>Загрузка...</p>}
{mutationError && <p>Ошибка: {mutationError.message}</p>}
</div>
)
})
}
Мы можем деструктурировать loading
и error
из объекта, возвращаемого useMutation()
для отслеживания состояния мутации и его отображения в UI
. useMutation()
также поддерживает onCompleted()
и onError()
, если вы предпочитаете колбеки.
useMutation API
useMutation()
принимает два аргумента:
mutation
- мутация, которая разбирается в абстрактное синтаксическое дерево с помощьюgql
options
- объект с настройками
Настройки
mutation
- данная настройка является опциональной, поскольку мутация может передаваться вuseMutation()
в качестве первого аргументаvariables: { [key: string]: any }
- объект с переменными для мутацииupdate: (cache, mutationResult) => void
- функция для обновления кеша после выполнения мутацииignoreResults: boolean
- если имеет значениеtrue
, свойствоdata
не будет обновляться результатами мутацииoptimisticResponse: object
- ответ от мутации, возвращаемый до получения результатов от сервераrefetchQueries
- массив функций, позволяющий определить, какие запросы должны быть запущены повторно после выполнения мутации. Значениями массива могут быть запросы (с опциональными переменными) или просто названиями запросов в виде строкawaitRefetchQueries: boolean
- запросы, выполняемые повторно как частьrefetchQueries
, обрабатываются асинхронно, поэтому мута ция может завершиться до их выполнения. Установка этого значения вtrue
сделает повторно выполняемые запросы частью выполняемой мутации, т.е. мутация будет считаться завершенной только после выполнения этих запросовonCompleted: (data) => void
- колбек, который запускается при успешном выполнении мутацииonError: (error) => void
- колбек, который запускается при возникновении ошибкиcontext
- общий для компонента и сетевого интерфейса (Apollo Link
) контекст. Может использоваться для установки заголовков на основе пропов или отправки информации в функциюrequest
изApollo Boost
client
- экземплярApolloClient
. По умолчаниюuseMutation()
использует клиент, переданный через контекст, но мы вполне можем передать другой клиент
Результат
Результатом, возвращаемым useMutation()
, является кортеж, состоящий из функции для запуска мутации и объекта, представляющего результат мутации.
Функция мутации вызывается для запуска мутации из UI
.
Результат мутации:
data
- данные из мутации. Может иметь значениеundefined
loading: boolean
- индикатор выполнения мутацииerror
- любая ошибка, возникшая в процессе выполнения мутацииcalled: boolean
- индикатор вызова функции мутацииclient
- экземплярApolloClient
. Может использоваться для вызова методов для работы с кешем, таких какclient.writeData
иclient.readQuery
, за пределами контекста функции обновления
Подписки / Subscriptions
В дополнение к запросам и мутациям GraphQL
поддерживает третий тип операций - подписки.
Как и запросы, подписки позволяют получать данные. Но в отличие от запросов, подписки - это длящиеся операции, результаты которых могут меняться со временем. Они могут поддерживать активное соединение с сервером GraphQL
(в основном, через веб-сокеты), позволяя серверу обновлять результаты.
Подписки могут использоваться для уведомления клиента об изменении данных на сервере в режиме реального времени, например, о создании нового объекта или обновлении важного поля.
Случаи использов ания:
- небольшие инкрементальные изменения больших объектов
- обновления в режиме реального времени (с низкой задержкой)
Определение подписки
Сервер
Подписки определяются в схеме GraphQL
как поля с типом Subscription
. В следующем примере подписка commentAdded
уведомляет подписанного клиента о добавлении нового комментария к определенному посту (на основе postID
):
type Subscription {
commentAdded(postId: ID!): Comment
}
Более подробно настройка подписки на сервере рассматривается в руководстве по Apollo Server
.
Клиент
На стороне клиента определяется форма каждой подписки, подлежащей выполнению:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
При выполнении подписки OnCommentAdded
, Apollo Client
устанавливает соединение с сервером и ждет от него ответа. В отличие от запроса, ответ от сервера не поступает сразу. Вместо этого сервер отправляет данные клиенту при возникновении определенного события.
{
"data": {
"commentAdded": {
"id": "123",
"content": "Какой замечательный пост!"
}
}
}
Настройка транспортного протокола
Поскольку подписки используют постоянное соединение, они не должны использовать HTTP
, который Apollo Client
использует для запросов и мутаций. Для обеспечения коммуникации через веб-сокеты используется поддерживаемая сообществом библиотека subscriptions-transport-ws
.
1. Установка библиотек
Apollo Link
- это библиотека, которая помогает кастомизировать сетевые коммуникации. Она может использоваться для определения цепочки ссылок, которые модифицируют операции и направляют их в определенный пункт назначения.
Для реализации подписки через веб-сокеты можно добавить в цепочку ссылок WebSocketLink
. Данная ссылка требует наличия subscriptions-transport-ws
. Устанавливаем ее:
yarn add subscriptions-transport-ws
# или
npm i ...
2. Инициализация WebSocketLink
Импортируем и инициализируем WebSocketLink
в том же файле, где инициализируется ApolloClient
:
import { WebSocketLink } from '@apollo/client/link/ws'
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true
}
})
uri
- это конечная точка веб-сокета, используемого подпиской на сервере.
3. Разделение коммуникации по операциям (рекомендуется)
Несмотря на то, что Apollo Client
может использовать WebSocketLink
для выполнения операций всех типов, в случае с запросами и мутациями следует использовать HTTP
. Это объясняется тем, что запросы и мутации не нуждаются в постоянном или длительном соединении с сервером, а также тем, что HTTP
является более эффективным и масштабируемым.
Библиотека @apollo/client
предоставляет функцию split
, которая позволяет использовать одну из указанных link
в зависимости от результата логической проверки.
В следующем примере инициализируется как WebSocketLink
, так и HttpLink
. Для их объединения в одну link
используется функция split
. Использование конкретной ссылки определяется на основе типа выполняемой операции:
import { split, HttpLink } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { WebSocketLink } from '@apollo/client/link/ws'
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql',
})
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true
}
})
/*
Функция `split` принимает 3 параметра:
* Функция, которая выполняется для каждой операции
* Ссылка, которая используется для операции, если функция возвращает истинное значение
* Ссылка, которая используется для операции, если функция возвращает ложное значение
*/
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsLink,
httpLink
)
Данная логика обеспечит использование HTTP
запросами и мутациями и WebSocket
подписками.
4. Передача цепочки ссылок клиенту
После определения цепочки ссылок, она передается в конструктор клиента:
import { ApolloClient, InMemoryCache } from '@apollo/client'
// ...
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
})
Значение настройки link
имеет приоритет над значением настройки uri
.
5. Аутентификация через веб-сокеты (опционально)
Часто возникает необходимость в аутентификации клиента перед предоставлением ему разрешения на получение результатов подписки. Это можно сделать с помощью настройки connectionParams
конструктора WebSocketLink
, например:
import { WebSocketLink } from '@apollo/client/link/ws'
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true,
connectionParams: {
authToken: user.authToken
}
}
})
WebSocketLink
передает серверу объект connectionParams
при установке соединения. Сервер должен иметь объект SubscriptionServer
для прослушивания соединений. При получении сервером объекта connectionParams
, он используется для выполнения аутентификации, наряду с другими зад ачами, связанными с соединением.
Выполнение подписки
Для выполнения подписки в компоненте используется хук useSubscription()
. Данный хук возвращает объект со свойствами loading
, error
и data
, которые могут использоваться для рендеринга UI
.
В следующем примере при отправке сервером нового комментария к определенному посту, выполняется повторный рендеринг компонента:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
function LatestComment({ postId }) {
const { loading, data } = useSubscription(COMMENTS_SUBSCRIPTION, {
variables: { postId },
})
return <h4>Новый комментарий: {!loading && data.commentAdded.content}</h4>
}
Подписка на обновления запроса
Результат, возвращаемый запросом включает в себя функцию subscribeToMore
. Эта функция может использоваться для выполнения последующей подписки, которая обновляет результат.
Функция subscribeToMore
по своей структуре похожа на функцию fetchMore
, которая, в основном, используется для обработки пагинации. Отличие между ними состоит в том, что fetchMore
выполняет следующий запрос, а subscribeToMore
- следующую подписку.
Определяем запрос на получение всех комментариев к определенному посту:
const COMMENTS_QUERY = gql`
query CommentsForPost($postId: ID!) {
post(postId: $postId) {
comments {
id
content
}
}
}
`
function CommentsPageWithData({ params }) {
const result = useQuery(COMMENTS_QUERY, {
variables: { postId: params.postId },
})
return <CommentsPage {...result} />
}
Предположим, что сервер отправляет обновления клиенту при добавлении нового комментария. Сначала необходимо определить подписку, которая будет выполняться при разрешении COMMENTS_QUERY
:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
Далее следует обновить функцию CommentsPageWithData
для добавления пропа subscribeToNewComments
возвращаемому компоненту CommentsPage
. Этот проп представляет собой функцию, отвечающую за вызов subscribeToMore
после монтирования компонента:
function CommentsPageWithData({ params }) {
const { subscribeToMore, ...result } = useQuery(COMMENTS_QUERY, {
variables: { postId: params.postId },
})
return (
<CommentsPage
{...result}
subscribeToNewComments={() =>
subscribeToMore({
document: COMMENTS_SUBSCRIPTION,
variables: { postId: params.postId },
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData) return prev
const newFeedItem = subscriptionData.data.commentAdded
return Object.assign({}, prev, {
post: {
comments: [newFeedItem, ...prev.post.comments]
}
})
}
})
}
/>
)
}
В приведенном примере мы передаем subscribeToMore
3 параметра:
document
- подписка для выполненияvariables
- переменные для подпискиupdateQuery
- функция для комбинации кешированных результатов запроса (prev
) с новыми данными (subscriptionData
), полученными от сервера. Значение, возвращаемое этой функцией, полностью заменяет кешированные результаты запроса
Наконец, в CommentsPage
мы выполняем подписку на новые комментарии при монтировании компонента:
function CommentsPage({ subscribeToNewComments }) {
useEffect(() => {
subscribeToNewComments()
}, [])
// ...
}
useSubscription API
Настройки
subscription
- подписка для выполнения. Данная настройка является опциональной, поскольку подписка может передаваться хукуuseSubscription()
в качестве первого аргументаvariables: { [key: string]: any }
- переменные для подпискиshouldResubscribe: boolean
- определяет, должна ли подписка выполнять отписку и повторную подпискуskip: boolean
- если имеет значениеtrue
, выполнение подписки пропускаетсяonSubscriptionData: (options) => any
- позволяет зарегистрировать колбек, который будет запускаться при каждом получении данныхfetchPolicy
- политика кеширования (по умолчанию имеет значениеcache-first
)client
- экземплярApolloClient
, используемый для выполнения подписки
Результат
data
- объект с результатами выполнения подписки (по умолчанию пустой объект)loading: boolean
- индикатор выполнения подпискиerror
- массивgraphQLErrors
или объектnetworkError
Фрагменты / Fragments
Фрагмент - это часть логики, которая может распределяться между несколькими запросами и мутациями.
Вот пример фрагмента NameParts
, который может быть использован любым объектом Person
:
fragment NameParts on Person {
firstName
lastName
}
Каждый фрагмент включает набор полей, принадлежащих связанному типу (assosiated type).
Мы можем включать фрагмент NameParts
в любые запросы и мутации, которые ссылаются на объекты Person
:
query GetPerson {
people(id: '123') {
...NameParts,
avatar(size: LARGE)
}
}
Если мы изменим набор полей в NameParts
, набор полей, включаемых в операции, в которых используется фрагмент, также изменится автоматически.
Пример использования
Предположим, что мы разрабатываем приложение для блога, в котором выполняется несколько операций, связанных с комментариями (добавление комментария, получения комментариев к определенному посту и т.д.). Вероятно, все эти опера ции будут включать одинаковый набор полей типа Comment
.
Для определения такого набора мы можем использовать фрагмент:
import { gql } from '@apollo/client'
export const CORE_COMMENT_FIELDS = gql`
fragment CoreCommentsFields on Comment {
id
postedBy {
username
displayName
}
content
createdAt
}
`
Затем мы можем включить данный фрагмент в операцию следующим образом:
import { gql } from '@apollo/client'
import { CORE_COMMENT_FIELDS } from './fragments'
export const GET_POST_DETAILS = gql`
${CORE_COMMENT_FIELDS}
query CommentsForPost($postId: ID!) {
post(postId: $postId) {
title
body
author
comments {
...CoreCommentFields
}
}
}
`
Совместное размещение (colocating) фрагментов
Структура ответа GraphQL
напоминает дерево компонентов. Благодаря этой схожести фрагменты могут использоваться для разделения логики запросов между компонентами, чтобы каждый компонент запрашивал только те поля, которые ему нужны.