State Management
GraphQL-кеш как состояние
В GitHub, чей публичный API построен на GraphQL, один объект Issue показан сразу в трёх местах: в списке задач, в карточке репозитория и в шапке самой задачи. Запрос изменил статус Issue, и все три места обновились разом, хотя пришли разными запросами. Это работа нормализованного кэша Apollo: он не хранит ответы как плоские снимки, а раскладывает их по объектам с типом и id. Один Issue#42 в кэше один на всё приложение. Поэтому в GraphQL-клиентах кэш это не вспомогательный слой при запросах - кэш сам и есть серверное состояние.
- GitHub: публичный GraphQL API, где один объект показан во многих местах и обновляется через общий кэш
- Shopify Storefront и Admin: GraphQL-данные о товарах и заказах, нормализованные по типу и id
- Apollo Client в дашбордах и админках: после мутации кэш обновляется и все вьюхи объекта перерисовываются разом
- urql в более лёгких проектах: тот же нормализованный кэш через пакет Graphcache, но компактнее
- Соцграфы и ленты, где сущность (пользователь, пост) встречается в десятках мест одного экрана
Предварительные знания
- Разделение серверного и клиентского состояния и идея кэша как источника данных
- Базовый GraphQL: запросы, мутации, типы и поля
- Понимание, что один и тот же объект может встречаться в разных частях ответа
Откуда взялась нормализация кэша в GraphQL
GraphQL Facebook открыла в 2015 году, и почти сразу встал вопрос клиентского кэша. Первый клиент Facebook, Relay, ввёл строгую нормализацию: каждый объект с глобальным id хранится в кэше один раз, а запросы лишь ссылаются на него. В 2016 году команда Apollo (тогда Meteor Development Group) выпустила Apollo Client с более доступным API, сохранив ту же идею нормализованного кэша по __typename и id. Позже Formidable выпустила urql как более лёгкую альтернативу, где нормализация подключается отдельным пакетом Graphcache. Во всех трёх клиентах общий принцип один: кэш это нормализованный граф объектов, и он же и есть состояние.
Нормализованный кэш и почему он и есть состояние
Кэш в TanStack Query и SWR хранит ответы плоскими снимками по ключу: под ключом лежит то, что вернул запрос, как есть. Нормализованный кэш в Apollo и urql устроен иначе. Он разбирает ответ на отдельные объекты и хранит каждый по уникальному ключу - обычно это пара __typename и id, например Issue:42. Один и тот же объект, встретившийся в разных запросах, в кэше существует один раз, а запросы лишь ссылаются на него.
Объект Issue:42 из обоих запросов кэшируется как одна запись. Если мутация изменила его status, и список, и детальная карточка отразят изменение разом, потому что обе ссылаются на одну запись в кэше. В плоском кэше пришлось бы инвалидировать оба запроса отдельно. Здесь же кэш это связный граф объектов, а не набор независимых снимков, поэтому он и выступает единым источником серверного состояния.
- Плоский кэш (Query, SWR) — Хранит ответ запроса целиком под ключом. Один объект в двух запросах - две копии. Синхронизация через инвалидацию ключей.
- Нормализованный кэш (Apollo, urql) — Разбирает ответ на объекты по __typename + id. Один объект - одна запись на всё приложение. Правка записи обновляет все ссылки разом.
Условие нормализации: у объекта должен быть стабильный идентификатор, который клиент умеет вычислить. По умолчанию это id или _id вместе с __typename. Если у типа нет id, его нельзя нормализовать, и Apollo хранит такой объект встроенным в родителя, а не отдельной записью.
Чем нормализованный кэш Apollo отличается от плоского кэша TanStack Query?
Чтение и запись кэша напрямую
Раз кэш это состояние, к нему нужен прямой доступ - не только через запросы к сети. Apollo даёт пары методов на клиенте кэша. readQuery и writeQuery читают и пишут данные в форме целого запроса. readFragment и writeFragment работают точечнее - с одним объектом по его ключу, без привязки к конкретному запросу. Это позволяет менять отдельную запись графа, и все вьюхи, которые на неё ссылаются, обновятся.
В urql прямой доступ к кэшу устроен похоже через пакет Graphcache: в update-резолверах доступны методы cache.writeFragment и cache.readFragment, а также cache.invalidate для удаления записи. Принцип общий для обоих клиентов: писать в граф можно адресно, по ключу объекта, а не только перезапрашивая весь запрос целиком.
| Метод (Apollo) | Уровень | Назначение |
|---|---|---|
| readQuery / writeQuery | Целый запрос | Прочитать или записать данные в форме запроса |
| readFragment / writeFragment | Один объект | Точечно прочитать или изменить запись по ключу |
| cache.modify | Поля объекта | Изменить отдельные поля записи без полного фрагмента |
| cache.evict | Запись | Удалить объект из кэша по ключу |
Прямая запись в кэш мощна, но обходит сервер: она меняет клиентскую копию, не спрашивая источник. Если записать в кэш то, чего нет на сервере, появится расхождение. Поэтому ручную правку кэша применяют осознанно - под оптимистичный отклик или под известный результат мутации, а не вместо сверки с сервером.
Зачем в Apollo нужны readFragment и writeFragment в дополнение к readQuery и writeQuery?
Обновление кэша после мутации
После мутации в GraphQL кэш обновляют двумя путями. Первый и самый простой: попросить мутацию вернуть изменённый объект с тем же __typename и id, и нормализованный кэш сам подменит запись. Это работает для правки существующего объекта без всякого ручного кода - клиент сопоставит ответ с записью по ключу. Второй путь нужен, когда меняется состав коллекции: добавление или удаление элемента списка.
Когда мутация добавляет или удаляет элемент списка, нормализация сама не догадается вставить или убрать его из коллекции - ссылку на список надо поправить вручную в update-функции. Там через cache.modify меняют поле-список объекта или через cache.evict удаляют запись. Это тот же класс задачи, что инвалидация списка после мутации в TanStack Query, только решённый прямой правкой графа, а не пометкой устаревшим и рефетчем.
- Правка существующего объекта: вернуть его из мутации с __typename и id, кэш обновится сам
- Добавление в список: update-функция вставляет ссылку на новый объект через cache.modify
- Удаление из списка: cache.evict убирает запись, затем cache.gc чистит висячие ссылки
- Альтернатива ручной правке: refetchQueries перезапрашивает затронутые запросы, как инвалидация в Query
Выбор между прямой правкой кэша и рефетчем такой же, как между setQueryData и invalidateQueries в TanStack Query. Прямая правка мгновенна и не ходит в сеть, но требует кода под каждый случай. Рефетч проще и надёжнее в сверке, но добавляет сетевой запрос.
Мутация изменила поле status существующего объекта Issue. Что обычно достаточно сделать, чтобы кэш Apollo обновился?
Связь с другими темами
Урок про кэш как состояние. Дальше тема связана так:
- Server-state против client-state — Нормализованный кэш это предельное выражение идеи, что серверные данные это кэш, а не переменная
- TanStack Query: мутации и optimistic — Обновление кэша после мутации решает ту же задачу, что инвалидация в Query, но иными средствами
Итог
- В GraphQL-клиентах (Apollo, urql) нормализованный кэш сам и есть серверное состояние, а не вспомогательный слой при запросах
- Нормализация: ответы не хранятся плоскими снимками, а раскладываются по объектам с ключом __typename плюс id. Один объект в кэше один на всё приложение
- Поэтому правка одного объекта обновляет все места, где он показан, даже если они пришли разными запросами
- Чтение и запись кэша идут напрямую: readQuery/readFragment и writeQuery/writeFragment в Apollo дают точечный доступ к графу
- После мутации кэш обновляют двумя путями: рефетч затронутых запросов или прямая правка кэша в update-функции под мгновенный отклик
Связанные уроки
- sm-30-server-vs-client-state — Нормализованный кэш это конкретная реализация идеи, что серверные данные это кэш, а не переменная
- sm-32-tanstack-mutations — Обновление кэша после мутации в GraphQL решает ту же задачу, что инвалидация в TanStack Query