Managing application cache with react-query. And code generation from OpenAPI.
Alexey Lysenko
Posted on May 31, 2022
Introduction
In this article, I would like to address the following aspects:
- What is application cache.
- react-query as a way to manage application cache.
- how on the project we use code generation from Open API in
npm package
with customreact-query
hooks and further we spread the code between two clients of Web i Mobile.
Until recently, the Web application on the project I'm working on used Redux
as the primary state manager, but now we've completely switched to react-query
. Let's take a look at what I personally think are the disadvantages of Redux
and why react-query
?
Why did Redux
take on the many projects by default? My answer is that thanks to Redux
we have architecture. That is, we have a Store in which we store the State of the entire application, we have Actions that we Dispatch when we need to change the store. And all the asynchronous operations we do are through crutch middleware
using mostly Thunk and Saga etc.
Now we realize, that the cool thing is that Redux
helps to make the architecture - what's wrong with it. I repeat this is my personal experience with him you can disagree.
Disadvantages of Redux:
1. Verbosity.
It's not very cool when you need to develop some kind of module in an existing application, constantly writing a bunch of code. Switching between different modules with. Action_type, action creators, Thunks, etc.
Writing fewer boilerplates not only increases the chance of making fewer mistakes, but also increases the readability of the code - and this is very cool, since you have to read and understand more often than write.
«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. Everything is stuffing into it.
When you're working on a big project with multiple developers. Again, this is my experience. The element of rush and deadlines encourages developers to start storing everything in the global store, even if we don't have to. Conditionally synchronous “handles” that switch private ui behavior in single modules. Requests to the server that are also used in the same module. All this is moved to the global store, and can obfuscate the code by increasing its cohesion.
3. Redux creates non-obvious hidden dependencies.
An example to get the data we get users in the Home.js component:
React.useEffect(() => {
dispatch(getUserData());
}, []);
And then having received the data, we use them in many other components (Transactions, Items, Menu ..). In this case, this creates a hidden dependency, because when refactoring the code, if we remove this dispatch(getUserData()) in only one place, it breaks userData in all other places in the application.
And more importantly, the mechanism for maintaining the data that we received from the server is not convenient. We constantly need to monitor the validity of this data and remember to update it if we know that it has changed on the server.
And here we come to 2 concepts of data in an application. We can split the data into State and Cache.
States are the data that needs to be saved and changed throughout the life of the application.
Cache is data received from outside, let's say http request.
And in the redux, we mix and store them in a state just because they are used in other places in the application.
So 90% of the data that we use in the application is cache.
At this point, I want to move on to the react-query cache management library. Give a brief overview and see how you can improve your developer experience with cache using this library.
Overview of React-Query
As written on the official site: Fetch, cache, and update data in your React and React Native applications all without touching any "global state". At their core, these are custom hooks that take control of the cache, giving us a lot of cool features, such as caching, optimistic update, etc. ... And what I like is that it removes a lot of intermediate abstractions, reducing the amount of code written. Let's go with an example.
Everything is simple here, we wrap the root of our application in a QueryClientProvider
:
import { QueryClient, QueryClientProvider } from 'react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ExampleFirst />
</QueryClientProvider>
)
}
Now we make a request in the component using axios get, which we pass to 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>
)
}
We wrapped our request in a useQuery
hook and got an API for working with data, and we leave control over loading, processing and interception of errors to the hook. useQuery
takes as its first parameter a unique query key. react-query
manages query caching based on query keys. Query keys can be as simple as a string, or as complex as an array of multiple strings and nested objects. The second parameter is our get request, which returns a promise. And the third, optional, is an object with additional configurations.
As you can see, this is very similar to the code when we learned how to work with server requests in React, but then everything turned out differently on a real project :) And we began to apply a large layer of abstractions on top of our code to catch errors, load status and everything else. In react-query
, these abstractions are brought under the hood and leave us with purely convenient APIs to work with.
In fact, this is the main example of using react-query
hooks for get requests. In fact, the API of what the hook returns is much larger, but in most cases we use these few { isLoading, error, data }
useQuery
also shares state with all other useQuery with the same key. You can call the same useQuery call multiple times in different components and get the same cached result.
For queries with data modification, there is a useMutation
hook. Example:
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>
);
}
Again, we pass axios.post(..)
to the hook, and we can directly work with the {isLoading, isSuccess, isError}
API and other values that useMutation provides. And we call the mutation itself using mutation.mutateAsync ()
. In this example, we see that we are passing an object with functions as the second parameter:
- this will work on successful completion of the post request and will return the data we received:
onSuccess(data) {
console.log("Succesful", data);
}
- will works if an error occurred, return an error:
onError(error) {
console.log("Failed", error);
},
- will work anyway, after query triggers:
onSettled() {
console.log("Mutation completed.");
}
In this object, we can put additional keys in order to control the data fetching process.
useMutation
will keep track of the state of the mutation in the same way that useQuery
does for queries. This will give you the isLoading
, isFalse
and isSuccess
fields so that you can easily display what is happening to your users. The difference between useMutation
and useQuery
is that useQuery
is declarative, useMutation
is imperative. By this I mean that useQuery
queries are mostly done automatically. You define the dependencies, but useQuery
will take care of executing the query immediately, and then also perform smart background updates if necessary. This works great for requests because we want what we see on the screen to be in sync with the actual data from the back-end. It won't work for mutations. Imagine that every time you focus the browser window, a new task will be created. So instead of triggering a mutation immediately, React Query provides you with a function that you can call whenever you want to mutate.
It is also recommended to create a custom hook in which we put our react-query hook:
const transformTodoNames = (data: Todos) =>
data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
select: transformTodoNames,
})
This is convenient because:
- you can store all uses of a single query key (and possibly type definitions) in a single file;
- if you need to tweak some settings or add data transformation, you can do it in one place.
And at this point, when familiarity with react-query is over. I'd like to show you how we can go even further with react-query and generate our hooks from an OpenAPI schema.
Code generation from OpenAPI
As we can see, all requests are separate hooks without being tied to store abstractions. Therefore, if we have a valid OpenApi
schema with a back-end, we can code-generate our hooks directly from the schema, and put it in a separate npm package. What will this give us:
- reduce the amount of manual work and boilerplate writing;
- simplify the architecture of the application;
- less code === less bugs
- we will reuse code on the web client, and on the mobile react native client.
From Wikipedia: The OpenAPI specification, originally known as Swagger, is a specification for machine-readable files with interfaces to describe, create, consume, and render REST web services.
I do not want to focus on the OpenApi
scheme, it is better to read about it on certain resources. But we will assume that we have the actual OpenAPI
json scheme of our REST requests. Next is an example of our custom library, which we use in our project. I'll go over the main points quickly to convey the general idea. Let's create a new project with the following structure:
src/operations/index.ts:
export * from './operations';
In .openapi-web-sdk-generatorrc.yaml
we need to configure the options:
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"
}
}
We use one package for code generation, all the others are needed so that our generated hooks receive dependencies after generation:
@straw-hat/openapi-web-sdk-generator
If we look at what this package is based on, we will see that we are using oclif - this is a node.js-based tool for creating a CLI.
Mustache.js is a template engine for creating js templates. cosmiconfig is a tool to make it convenient to work with the configuration.
In package.json we configure:
"oclif": {
"commands": "./dist/commands",
"bin": "sht-openapi-web-sdk-generator",
"plugins": [
"@oclif/plugin-help"
]
}
Let's look in ./dist/commands, we have the local.ts
file there:
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());
}
}
We will inherit LocalCommand
from BaseComand
- this abstract class BaseCommand extends Command is the class that serves as the basis for each oclif command. And in the run()
function, we set up the config and return Promise.all(generator.generate())
; generator is an instance of the OpenapiWebSdkGenerator
class with a description of the generator logic. This will be our code generation command.
Now let's see what are our classes from which we generate code: src/generators/react-query-fetcher
Here is how we generate code from a template:
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');
}
}
We see that according to different conditions that we take from the schema, we generate useQuery or useMutation templates from the query-operation.ts.mustache
or mutation-operation.ts.mustache
template, respectively:
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}}},
});
}
Excellent! Very superficially figured out how our code generation works.
Finishing and starting the generator
Let's return to the test project. We take the OpenAPI
schema and put it in the specification folder:
What remains for us is to run the command in the console:
yarn codegen:sdk
In the console we see something like:
All of our custom hooks are generated and we can see them in the operations folder:
Now we can download and use these hooks as a standalone npm package
in our project.
Posted on May 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.