Управляем cache с react-query. Кодогенерация

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем привет. Я Алексей Лысенко, JS Software Engineer в компании Kozak Group. Сейчас работаю на американский стартап HMbradley, это банковское приложение по типу монобанка. Мы используем React/Next.js на web и ReactNative под iOS и Android. Я работаю в веб-команде.

В этой статье я бы хотел затронуть такие аспекты:

  • что такое application cache;
  • react-query как способ управлять application cache;
  • как мы на проекте используем кодогенерацию из Open API в npm пакет с custom react-query hooks и в дальнейшем шарим код между двумя клиентами web i mobile.

До недавних времен Web-приложение использовало Redux в качестве основного стейт менеджера, сейчас же мы полностью перешли на React Query. Давайте взглянем, в чем, я считаю, есть недостатки Redux и зачем react-query?

Почему на все проекты брали Redux? У меня ответ в том, что благодаря Redux у нас появляется архитерура. То есть у нас есть Store, в котором мы храним State всего приложения, у нас есть Actions, которые мы Dispatch’им, когда нужно изменить стору. И все асинхронные операции мы делаем через костыль middleware, используя в основном Thunk и Saga и т. д.

Теперь поняв, что крутость в том, что redux помогает сделать архитектуку — что в нем плохого. Повторяюсь, это мой личный опыт, с ним можно быть не согласным.

Недостатки Redux

1️⃣ Многословность. Не очень прикольно, когда нужно разработать какой-то модуль в существующем приложении, постоянно писать кучу кода. Переключаясь между различными модулями с. Action_type, action creators, Thunks и т. д. Писать меньше бойлерплейтов не только повышает шанс сделать меньше ошибок, но и увеличивает удобность читаемости кода, а это очень прикольно, так как читать и разбираться приходится чаще, чем писать.

«All code is buggy. It stands to reason, therefore, that the more code you have to write the buggier your apps will be.» — RICH HARRIS

2️⃣ В него пихают все. Когда работаешь над большим проектом с несколькими разработчиками. Опять-таки, это мой опыт. Элемент спешки и дедлайнов подталкивает к тому, что разработчики начинают хранить все в глобальном сторе, даже если у нас нет необходимости это делать. Условно синхронные «ручки», переключающие частное поведение UI в единичных модулях. Запросы на сервер, которые тоже используются в одном модуле. Все это перемещается в глобальный store, и может запутать код, увеличив его зацепление.

3️⃣ Redux создает неочевидные скрытые зависимости. Пример, чтобы получить данные. Мы в компоненте Home.js получаем юзеров:

React.useEffect(() => {
      dispatch(getUserData()); 
  }, []);

И дальше, получив данные, используем их во множестве других компонентах (Transactions, Items, Menue..). В таком случае это порождает скрытую зависимость, так как при рефакторинге кода, если мы уберем этот dispatch(getUserData()) только в одном месте, ломает userData во всех остальных местах приложения. И что еще важно, механизм поддерживания данных, которые мы получили с сервера, не удобен. Нам постоянно нужно следить за валидностью этих данных и не забывать обновлять их, если мы знаем, что они поменялись на сервере.

И вот здесь мы приходим к двум концепциям данных в приложении. Мы можем разделить данные на Состояние и Cache.

Состояния — это те данные, которые нужно сохранять и изменять на протяжении всей жизни приложения. Cache — это данные полученные извне, допусти http-запросом.

И в редаксе мы смешиваем и храним их в состоянии только из-за того, что они используютя в других местах приложения. Так вот, 90% данных, которые мы используем в приложенни, являются кешем. Есть хороший ролик Ильи Климова об этом.

На этом моменте я хочу перейти к библиотеке для управления кешем react-query. Дать краткий обзор и посмотреть, как можно улучшить developer expirience в работе с кешем, используя эту библиотеку.

Обзор React-Query

Источник

Как написанно на официальном сайте, Fetch, cache, and update data in your React and React Native applications all without touching any «global state». По своей сути это кастомные хуки, которые берут на себя управление кешем, давая нам много прикольных фишек, таких как кеширование, оптимистический апдейт, и.т. д... И что нравится мне — убирает множество промежуточных абстракций, уменьшая количество написаного кода. Погнали на примере.

Тут все просто, оборачиваем корень нашего приложения в QueryClientProvider:

import { QueryClient, QueryClientProvider } from 'react-query'
  const queryClient = new QueryClient()
  export default function App() {
   return (
     <QueryClientProvider client={queryClient}>
       <ExampleFirst />
     </QueryClientProvider>
   )
 }

Теперь в компоненте делаем запрос при помощи axios get, который передаем в useQuery:

import {  useQuery } from 'react-query'
import axios from 'axios'

 function ExampleFirst() {
   const { isLoading, error, data } = useQuery('repoData', async () =>
    const res = await axios.get('https://api.github.com/repos/react-query')
    return res.data
   )
 
   if (isLoading) return 'Loading...'
   if (error) return 'An error has occurred: ' + error.message
 
   return (
     <div>
       <h1>{data.name}</h1>
       <p>{data.description}</p>
       <strong>👀 {data.subscribers_count}</strong>{' '}
       <strong>✨ {data.stargazers_count}</strong>{' '}
       <strong>🍴 {data.forks_count}</strong>
     </div>
   )
 }

Мы обернули наш запрос в useQuery хук и получили API для работы с данными, а контроль за загрузкой, обработку и перехват ошибок мы оставляем хуку. useQuery принимает первым параметром уникальный ключ запроса. React Query управляет кэшированием запросов на основе ключей запроса. Ключи запроса могут быть как простыми, как строка, так и сложными, как массив из множества строк и вложенных объектов. Второй параметр — это и есть наш get-запрос, который возвращает промис. И третий, необязательный, является объектом с дополнительными конфигурациями.

Как видим, это очень похоже на тот код, когда мы учились работать с запросами на сервер в React, но потом на реальном проекте все оказалось по другому :) И мы начали применять большой слой абстракций поверх нашего кода, для отлавливания ошибок, статуса загрузки и всего прочего. В React Query же эти абстракции заведенны под капот и оставляют нам сугубо удобные API для работы.

По сути, это и есть основной пример использования React Query хуков для get-запросов. На самом деле API того, что возвращает хук намного больше, но в большинстве случаев мы используем именно эти несколько { isLoading, error, data }

useQuery так же делит состояние со всеми другими useQuery с таким же ключем**.** Вы можете вызвать один и тот же вызов useQuery несколько раз в разных компонентах и получить один и тот же кэшированный результат.

Для запросов с модификацией данныx есть хук useMutation. Пример:

export default function App() {
  const [todo, setTodo] = useState("");

  const mutation = useMutation(
    async () =>
      axios.post("https://jsonplaceholder.typicode.com/todos", {
          userId: 1,
          title: todo,
        }),
    {
      onSuccess(data) {
        console.log("Succesful", data);
      },
      onError(error) {
        console.log("Failed", error);
      },
      onSettled() {
        console.log("Mutation completed.");
      }
    }
  );

  async function addTodo(e) {
    e.preventDefault();
    mutation.mutateAsync();
  }

  return (
    <div>
      <h1>useMutations() Hook</h1>
      <h2>Create, update or delete data</h2>
      <h3>Add a new todo</h3>
      <form onSubmit={addTodo}>
        <input
          type="text"
          value={todo}
          onChange={(e) => setTodo(e.target.value)}
        />
        <button>Add todo</button>
      </form>
      {mutation.isLoading && <p>Making request...</p>}
      {mutation.isSuccess && <p>Todo added!</p>}
      {mutation.isError && <p>There was an error!</p>}
    </div>
  );
}

Опять-таки мы передаем в хук axios.post(..), и на прямую можем работать с API {isLoading, isSuccess, isError} и другими значениями, которые предоставляет useMutation. А саму мутацию мы вызываем посредством mutation.mutateAsync(). В данном примере мы видим, что вторым параметром мы передаем объект с функциями:

Это сработает при удачном завершении post-запроса и вернет данные, которые мы получили:

onSuccess(data) {
        console.log("Succesful", data);
  }

Сработает, если произошла ошибка, вернет ошибку:

onError(error) {
        console.log("Failed", error);
      },

Сработает в любом случае:

 onSettled() {
        console.log("Mutation completed.");
      }

В этот объект мы можем помещать дополнительные ключи для того, чтобы управлять процессом фетчинга данныx.

useMutation будет отслеживать состояние мутации так же, как это делает useQuery для запросов. Это даст вам поля isLoading, isFalse и isSuccess, чтобы вам было легко отображать то, что происходит, для ваших пользователей. Отличие useMutation от useQuery в том, что useQuery является декларативным, useMutation — императивным. Под этим я подразумеваю, что запросы useQuery, в основном, выполняются автоматически. Вы определяете зависимости, но useQuery позаботится о немедленном выполнении запроса, а затем также при необходимости выполняет интеллектуальные фоновые обновления. Это отлично работает для запросов, потому что мы хотим, чтобы то, что мы видим на экране, синхронизировалось с фактическими данными c back-end. Для мутаций это не сработает. Представьте, что каждый раз, когда вы фокусируете окно браузера, будет создаваться новая задача. Таким образом вместо мгновенного запуска мутации, React Query предоставляет вам функцию, которую вы можете вызывать всякий раз, когда хотите произвести мутацию.

Так же рекомендуется создавать кастомный хук, в который мы помещаем наш react-query hook:

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
  useQuery(['todos'], fetchTodos, {
    select: transformTodoNames,
  })

Это удобно, так как:

  • можно хранить все случаи использования одного ключа запроса (и, возможно, определения типов) в одном файле;
  • если вам нужно настроить некоторые параметры или добавить преобразование данных, вы можете сделать это в одном месте.

И на этом месте когда знакомство с react-query закончено. Я бы хотел показат, как можно пойти еще дальше в использовании react-query и генерировать наши хуки из OpenAPI схемы.

Кодогенерация из OpenAPI

Как мы можем убедится, все запросы — это отдельные хуки без привязки к абстракциям стора. Поэтому если у нас есть валидная OpenApi схема с back-end, мы можем кодгенерировать наши хуки прямиком из схемы, и вынести это в отдельный npm-пакет. Что это нам даст:

  • уменьшим количество ручной работы и написание бойлерплейта;
  • упростим архитектуру приложения;
  • меньше кода === меньше ошибок’
  • будем переиспользовать код b на web-клиенте, и на мобильном react native клиенте.

Из Википедии: спецификация OpenAPI, первоначально известная как Swagger — это спецификация машиночитабельных файлов с интерфейсами, для описания, создания, использования и визуализации REST веб сервисов.

Не хочу заострять внимание на OpenApi схеме, лучше почитать об этом на определенных ресурсах. Но будем считать, что мы имеем актуальную OpenAPI json схему наших REST-запросов. Дальше — пример нашей кастомной библиотеки, которую мы используем у себя на проекте. Я бегло пройдусь по основным моментам, чтобы передать общую идею. Давайте создадим новый проект с такой структурой:

src/operations/index.ts выглядит так:

export * from './operations';

В .openapi-web-sdk-generatorrc.yaml нужен нам для того чтобы сконфигурировать параметры:

generators:
  - path: "@straw-hat/openapi-web-sdk-generator/dist/generators/react-query-fetcher"
    config:
      outputDir: "src/operations"
      packageName: "@super/test"

В package.json:

{
  "name": "@super/test",
  "version": "1.0",
  "description": "test",
  "license": "UNLICENSED",
  "scripts": {
    "prepack": "yarn build",
    "codegen:sdk": "sht-openapi-web-sdk-generator local --config='./specification/openapi.json'"
  },
  "type": "commonjs",
  "main": "dist/index.js",
  "typings": "dist/index.d.ts",
  "files": [
    "dist",
  ],
  "dependencies": {
    "@straw-hat/react-query-fetcher": "^1.3.1"
  },
  "peerDependencies": {
    "@straw-hat/fetcher": "^4.8.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-query": "^3.34.8"
  },
  "devDependencies": {
    "@straw-hat/fetcher": "^4.8.2",
    "@straw-hat/openapi-web-sdk-generator": "^2.4.2",
    "@straw-hat/tsconfig": "^3.0.2",
    "@types/jest": "^27.4.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-query": "^3.34.12"
  }
}

Для кодгенерации используем один пакет, все остальные нужны для того, чтобы наши сгенерированные хуки получали зависимости после генерации.

@straw-hat/openapi-web-sdk-generator

Если посмотрим, на чем базируется данный пакет, то увидим, что мы используем oclif — это базируемая на node.js тулзовина для создания CLI.

Mustache.js — темплейт engine для создания темплейтов на js. cosmiconfig — тулза для того, чтобы можно было удобно работать с конфигурацией.

В package.json мы конфигурируем:

"oclif": {
    "commands": "./dist/commands",
    "bin": "sht-openapi-web-sdk-generator",
    "plugins": [
      "@oclif/plugin-help"
    ]
  }

Давайте посмотрим в ./dist/commands, там у нас находится файл local.ts:

import { flags } from '@oclif/command';
import { OpenapiWebSdkGenerator } from '../openapi-web-sdk-generator';
import { readOpenApiFile } from '../helpers';
import { BaseCommand } from '../base-command';

export default class LocalCommand extends BaseCommand {
  static override description = 'Generate the code from a local OpenAPI V3 file.';

  static override flags = {
    config: flags.string({
      required: true,
      description: 'OpenAPI V3 configuration file.',
    }),
  };

  async run() {
    const { flags } = this.parse(LocalCommand);

    const generator = new OpenapiWebSdkGenerator({
      context: process.cwd(),
      document: await readOpenApiFile(flags.config),
      config: this.configuration,
    }).loadGenerators();

    return Promise.all(generator.generate());
  }
}

Мы унаследуем LocalCommand от BaseComand — этот абстрактный class BaseCommand extends Command класс, который служит основой для каждой команды oclif. И в run() функции мы сетапим конфиг и возвращаем Promise.all(generator.generate()); generator — это инстанс класса OpenapiWebSdkGenerator c описанием логики генератора. Это и будет наша команда для генерации кода.

Теперь посмотрим, что является нашими классами, с которых мы генерируем код:

src/generators/react-query-fetcher

Здесь описано то, как мы с шаблона генерируем код:

import { CodegenBase } from '../../codegen-base';
import { OperationObject, PathItemObject } from '../../types';
import { forEachHttpOperation, getOperationDirectory, getOperationFileRelativePath } from '../../helpers';
import path from 'path';
import { OutputDir } from '../../output-dir';
import { TemplateDir } from '../../template-dir';
import { camelCase, pascalCase } from 'change-case';
import { OpenAPIV3 } from 'openapi-types';

const templateDir = new TemplateDir(
  path.join(__dirname, '..', '..', '..', 'templates', 'generators', 'react-query-fetcher')
);

function isQuery(operationMethod: string) {
  return OpenAPIV3.HttpMethods.GET.toUpperCase() == operationMethod.toUpperCase();
}

export interface ReactQueryFetcherCodegenOptions {
  outputDir: string;
  packageName: string;
}

export default class ReactQueryFetcherCodegen extends CodegenBase<ReactQueryFetcherCodegenOptions> {
  private readonly packageName: string;
  readonly #outputDir: OutputDir;

  constructor(opts: ReactQueryFetcherCodegenOptions) {
    super(opts);
    this.#outputDir = new OutputDir(this.options.outputDir);
    this.packageName = opts.packageName;
  }

  #processOperation = async (args: {
    operationMethod: string;
    operationPath: string;
    pathItem: PathItemObject;
    operation: OperationObject;
  }) => {
    const operationDirPath = getOperationDirectory(args.pathItem, args.operation);
    const operationFilePath = `use-${getOperationFileRelativePath(operationDirPath, args.operation)}`;
    const functionName = camelCase(args.operation.operationId);
    const typePrefix = pascalCase(args.operation.operationId);
    const pascalFunctionName = pascalCase(args.operation.operationId);
    const operationIndexImportPath = path.relative(
      this.#outputDir.resolveDir('index.ts'),
      this.#outputDir.resolve(operationFilePath)
    );

    await this.#outputDir.createDir(operationDirPath);

    const sourceCode = isQuery(args.operationMethod)
      ? await templateDir.render('query-operation.ts.mustache', {
          functionName,
          typePrefix,
          pascalFunctionName,
          importPath: this.packageName,
        })
      : await templateDir.render('mutation-operation.ts.mustache', {
          functionName,
          typePrefix,
          pascalFunctionName,
          importPath: this.packageName,
        });

    await this.#outputDir.writeFile(`${operationFilePath}.ts`, sourceCode);
    await this.#outputDir.formatFile(`${operationFilePath}.ts`);

    await this.#outputDir.appendFile(
      'index.ts',
      await templateDir.render('index-export-statement.ts.mustache', {
        operationImportPath: operationIndexImportPath,
      })
    );
  };

  async generate() {
    await this.#outputDir.resetDir();
    await forEachHttpOperation(this.document, this.#processOperation);
    await this.#outputDir.formatFile('index.ts');
  }
}

Видим, что по разных условиях, которые берем из схемы, мы генерируем с темплейта query-operation.ts.mustache или mutation-operation.ts.mustache шаблоны useQuery или useMutation соответсвенно:

import type { Fetcher } from '@straw-hat/fetcher';
import type { UseFetcherQueryArgs } from '@straw-hat/react-query-fetcher';
import type { {{{typePrefix}}}Response, {{{typePrefix}}}Params } from '{{{importPath}}}';
import { createQueryKey, useFetcherQuery } from '@straw-hat/react-query-fetcher';
import { {{{functionName}}} } from '{{{importPath}}}';

type Use{{{pascalFunctionName}}}Params = Omit<{{{typePrefix}}}Params, 'options'>;

type Use{{{pascalFunctionName}}}Args<TData, TError> = Omit<
  UseFetcherQueryArgs<{{{typePrefix}}}Response, TError, TData, Use{{{pascalFunctionName}}}Params>,
  'queryKey' | 'endpoint'
>;

const QUERY_KEY = ['{{{functionName}}}'];

export function use{{{pascalFunctionName}}}QueryKey(params?: Use{{{pascalFunctionName}}}Params) {
  return createQueryKey(QUERY_KEY, params);
}

export function use{{{pascalFunctionName}}}<TData = {{{typePrefix}}}Response, TError = unknown>(
  client: Fetcher,
  args: Use{{{pascalFunctionName}}}Args<TData, TError>,
) {
  return useFetcherQuery<{{{typePrefix}}}Response, TError, TData, Use{{{pascalFunctionName}}}Params>(client, {
    ...args,
    queryKey: QUERY_KEY,
    endpoint: {{{functionName}}},
  });
}

Отлично! Весьма поверхностно разобрались, как работает наша кодгенерация.

Заканчиваем и запускаем генератор

Вернемся к тестовому проекту. Берем OpenAPI схему и помещаем ее в папку specification:

Что нам остается, так это в консоли запустить команду:

yarn codegen:sdk

В консоли видим что-то на подобии:

Все наши кастомные хуки сгенерированны и мы можем увидеть их в папке operations:

Теперь мы можем загрузить и использовать эти хуки как отдельный npm-пакет в нашем проекте.

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

на счет недостатков Redux, это вовсе не недостатки.

Многословность.

и плюс и минус. это плата за «архитектуру».

В него пихают все.

redux тут не причем, если не контролировать, разработчики, в спешке или по неопытности, любую архитектуру нарушат таким образом. смотрите module boundaries в NX.

Redux создает неочевидные скрытые зависимости

похоже на предидущий пункт. проблема в дизайне приложения, у такой команды и без редакса будут такие же проблемы. юнит тесты по идее должны выявить такие проблемы.

Что хорошо в react-query, мы локализируем все изменения касательные http запросов непосредственно там где запросы вызываются. Поэтому разработчикам такие изменения проще контролировать, и они минимально зацеплены с остальными частями приложение.

и плюс и минус.

Ніяких плюсів. Verbosity це лише мінус.

cash — наличка
cache — кэш (в смысле данные)

Все правильно. Но опечатка по Фрейду))

Цікава тема. Було б класно якби стаття була українською

А можна детальніше описати як отримувати готівку через HTTP?
Здається ми тут чимось не тим займаємось :)

Що, реально готівка прилітає «извне http-запросом»?)

так, але круглі суми тільки через https)))

З генерацією цікава штука, треба буде ближче подивитися.

Якщо у вас вже є досвід використання Redux, то раджу ознаїомитись з RTK-query (Redux Toolkit Query). Це теж реалізація паттерна SWR, але ви залишаєтесь в межах знайомого стейт менеджера, який вам все одно скоріш за все знадобиться.

А в цілому підхід SWR (Stale While Revalidate) мені здається трошки недооціненим. Хоча дуже сипльно спрощує розробку. Але звісно не срібна куля і також є свої «вузькі місця»

Я писав на RTK ще до того моменту як туди завезли query, треба глянути. Cам RTK — подобається.
Думаю підхід недооцінюють бо на багатьох проектах беруть за замовченням перевірений стек. Я й сам не знав як це покаже себе на великому застосунку. Але все супер, особливий кайф що різні роути мінімально залежать один від одного, так як данні ми отримуємо в конкретному місці не прокидаючи їх через всю апку.

Щодо «прокидання черкз всю апку» , я вже на досвіді впевнився що дані треба отримувати якомога ближче до того місця де ми їх рендеримо. Це суттєво спрощує розробку і не тільки роути мінімально залежать одне від одного, але й віджети вцілому.

Підписатись на коментарі