Currently, APIs have become an essential component of modern software development. However, due to their complex integration experience, it is not always an easy process for developers. To simplify this process, Zodios
offers a solution that can help you free yourself from the complexities of API integration, making it easier for you to develop applications. In this article, we will explore how to use Zodios
to reshape your API integration experience and provide you with a simpler and more efficient development solution.
The Chinese version simultaneously published on Medium: 從繁到簡:使用 Zodios 重塑您的 API 串接體驗
Technical Background
The technical foundation for multiple large-scale products in "Crescendo Lab" mainly adopts Typescript and React. Typescript helps us write more stable and maintainable code, while React is a popular JavaScript front-end framework that allows us to quickly build highly interactive web applications.
Our web application is presented in the form of a Single Page Application (SPA), providing a smooth user experience. Meanwhile, we utilize RESTful APIs for data exchange to better manage and control data transmission. It is worth mentioning that due to frequent updates to our company's product features, we often need to repeatedly verify APIs and add new features. Therefore, the integration work related to APIs is very burdensome for us.
The following, we will list several pain points or common issues that we have encountered during development in this environment, and present our perspectives, analyze the problems, and search for solutions.
Incorrect Use of useEffect
Here's a classic example of misusing useEffect
:
useEffect(() => {
async function fetchData() {
const response = await fetch(`https://example.com/api/orgs/${orgId}`);
const json = await response.json();
setData(json);
}
fetchData();
}, [orgId]);
This approach may trigger an error warning when unmounting, and may also result in a race condition when switching orgId
. Some may even consider disabling the eslint rule react-hooks/exhaustive-deps
to take control of when the useEffect
behavior is triggered, but these actions are extremely dangerous and can disrupt the developer experience.
The correct approach is to use an abort controller signal or a cancel flag. There is plenty of information about this available online, and it is not the focus of this article, so we will not go into detail here.
In addition, this example also includes improper URL composition, which will be further discussed below.
Poor URL composition method
The parameters that may appear in a URL include path parameters and search parameters. The most common mistake is the incorrect concatenation of these parameters, such as in the following example:
const url = `users/${userId}/tags?q=${name}`;
The concatenation method above may cause errors due to the presence of certain characters, therefore it is necessary to use urlencode
:
const url = `users/${strictUriEncode(userId)}/tags?q=${strictUriEncode(name)}`;
However, this concatenation method is really ugly. Especially in the part of search parameters, it also lacks flexibility. Therefore, we prefer to use path-to-regexp
together with query-string
or qs
library:
const url = pathToRegexp.compile('users/:userId/tags')({ userId }) + '?' + queryString.stringify({ q: name });
In this way, we have successfully extracted parameters from the URL. However, we still have another challenge to solve because our API uses the snack_case
naming convention, while the front-end coding style uses camelCase
.
snake_case
vs camelCase
Due to the different naming conventions, the frontend needs to convert both the search parameter keys and the property keys inside the request body to snake_case
before making a request. After receiving the response, the property keys inside the data need to be converted to camelCase
. This process is tedious and repetitive. Ideally, the conversion should be done at a lower level and should not be a concern during usage.
Conversion Issues with Date
Type
Due to the inability to transmit Date
objects in APIs, we typically use serialized data with common formats such as ISO-8601
/ RFC3339
(sending numbers is considered a hack). Each time we obtain a value in a component, it is in string format and requires additional conversion when used. If we could consistently obtain Date objects from the start, the user experience when working with the data would be much better. In addition to clearly knowing that the value is a Date (otherwise it is a string), we could also directly use Date
methods. If using Day.js
, using Day.js
objects directly would also be a good choice.
Misunderstandings Caused by Human Language
This is a true story that happened in our company. We designed a new specification for a specific resource. At the time, the agreement between the backend engineer and the frontend was that the old version would not have a certain property, only the new version would. Without this record, I (as the frontend engineer) used a very aggressive judgment method, hasOwnProperty
. However, the backend engineer only returned the property value as null
. This resulted in a judgment error because I passed the old version data to the new version processing method, ultimately causing a program error and interruption.
Another interesting example often occurs in the communication between engineers and designers, which is the definition of "required". From an API perspective, "required" means that the field must exist, but from a user's perspective, it means "non-empty".
Such information disparity is easy to occur in human communication interfaces with simple descriptions. However, if we can use tools such as schemas or documents to help clarify and even provide playground testing, we can truly achieve consensus through a unified language.
In the case of inconsistent type definitions, errors often occur during program execution, making it difficult to trace the problem. Therefore, a better approach is to perform preliminary data validation after receiving the API response. From a team perspective, this allows the team to determine whether the problem should be handled by the frontend or backend engineer as soon as possible. Some people may think that in most cases, skipping validation can still allow the program to run normally. However, this optimistic approach ultimately makes the program unreliable. However, for methods such as GraphQL
or tRPC
that can generate frontend and backend interfaces simultaneously through schema, additional data validation is not necessary.
Scattered APIs
Mixing API implementation with the app makes maintenance difficult. For instance, if we make adjustments to the API, it will be challenging to locate the affected code within a large project. Therefore, a more ideal approach is to place the API definitions in a separate package, so that once there is any change to the API specifications, we only need to make corresponding adjustments in the API package.
Cache Key Chaos
The most common challenge for beginners when using TanStack Query
or SWR
is managing the cache and cache keys. To ensure that updates are reflected in the interface, we usually invalidate the specified query after the request to resynchronize data with the backend. Managing this in a component can be very cumbersome and prone to omissions during development or maintenance. Complex cache management, like memory management, can be quite challenging. While we do pay attention to memory usage when writing JavaScript programs, we don't actually issue commands to control it, do we? Establishing a logic or strategy to automate the management of cache and cache keys, and even automatically updating data, can greatly reduce the development burden. The simplest and most extreme approach is to invalidate all queries after every mutate, and tRPC
provides such a strategy: https://trpc.io/docs/useContext#invalidate-full-cache-on-every-mutation
Encounter with Zodios
In order to solve the aforementioned issues, we established some standards to evaluate tools during our search:
- To avoid the cognitive burden caused by using
useEffect
, it is necessary to be able to integrateTanStack Query
or other similar tools. - It must be possible to define
Date
and convertISO8601
in the response toDate
type. - Definitions must be simple and clear.
- It would be even better if type guards can be provided.
Recently, I discovered that Zod
released the z.coerce
feature in v3.20
, particularly the z.coerce.date()
function which can both validate and convert date formats returned from APIs. As a result, I began searching for solutions in the Zod
ecosystem and found Zodios
.
Zodios
is a convenient tool that allows you to implement code as documentation by simply describing the API specification. It uses zod
to achieve end-to-end type safety. Additionally, thanks to its integration with axios
and Tanstack Query
, it delivers excellent results in terms of extensibility, development experience, and even user experience. Here is a simple example that illustrates how to use Zodios
from defining the API specification to applying it in a component:
import { makeApi, Zodios } from '@zodios/core';
import { ZodiosHooks } from '@zodios/react';
import { z } from 'zod';
const api = makeApi([
{
alias: 'getUsers',
method: 'get',
path: 'orgs/:orgId/users',
response: z.array(
z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.coerce.date(),
}),
),
},
]);
const apiClient = new Zodios('/api', api);
const apiHooks = new ZodiosHooks('myAPI', apiClient);
const query = apiHooks.useGetUsers({
params: {
orgId,
},
});
Prior to this, we have adjusted the structure of the pnpm workspace
. We created a new package in this repository specifically for defining APIs. In this project, we created a file called models.ts
to define the schema of all models, which can be thought of as the concept of Open API's Models. At the same time, we try to make the models reusable. For some special cases, we handle them separately inline in the endpoint. For example, if a specific API lacks a status field in the response, we will simply omit it, like this:
// frontend-repo/packages/api-sdk/user.ts
{
alias: 'update',
method: 'put',
path: 'api/v1/orgs/:orgId/users/:userId',
parameters: [
{
name: 'body',
type: 'Body',
schema: CreateUpdateRequestBodySchema,
},
],
response: UserSchema.omit({
status: true,
}),
},
We have divided the API functions into multiple independent modules, each of which is a separate file and uses its own makeAPI()
. In addition, we have made some adjustments to @zodios/react
, so that each module can automatically invalidate queries of the same module. We can also establish relationships between modules so that they can influence each other, thereby avoiding the trouble of managing cache and cache keys, and not invalidating all queries on the screen every time there is a mutate.
/frontend-repo
└── packages
├── api-sdk
│ ├── api
│ │ ├── message.ts
│ │ ├── organization.ts
│ │ ├── team.ts
│ │ └── user.ts
│ └── models.ts
└── app
├── src
├── vite.config.ts
├── package.json
└── tsconfig.json
It is worth noting that the CreateUpdateRequestBodySchema
in the above example will be kept in the file of the module instead of being placed in the globally shared models.ts
. This helps to avoid the models.ts
becoming too bulky due to excessive repetitive content used only in specific areas.
As Zodios
integrates with axios
, we take advantage of this feature to convert the request body and query string from camelCase
to snake_case
on axios
, as well as converting the response from snake_case
to camelCase
. We also handle token management and baseURL
on axios
. Additionally, useMutation().reset()
itself does not abort requests, so even if the status is reset, the onSuccess
, onError
, and onSettled
callbacks will still be triggered when the response returns. Therefore, we have created a simple shared function that can be inserted into the object returned by useMutation
, which can reset the mutation and truly cancel the request at the same time.
We will send the final result to an object called cantata
(also the development code name for this product's server). From now on, we only need to write the API specification in this package, and we can directly use all API requests through this object. Once the specification changes, we can immediately understand the affected range and make corresponding adjustments through typescript's type checking.
const userListQuery = cantata.user.useList({ orgId });
const currentUserQuery = cantata.user.userGetById({ orgId, userId });
const updateCurrentUserMutation = cantata.user.useUpdate({ orgId, userId });
// Note that both `userListQuery` and `currentUserQuery` will be automatically invalidated after each mutation.
API Guidelines
list
or getById
queries should use the GET
method, while mutations such as create
/ update
/ delete
/ enable
/ disable
should use POST
, PUT
, PATCH
, DELETE
methods. For some queries, such as search
, using POST
instead of GET
may be necessary. In this case, adding immutable: true
to the API definition will change the API from a mutation to a query.
To make the use of API more concise and clear, we recommend using aliases instead of useQuery
and useMutation
when calling the API. This can avoid the impact of cumbersome path and method on the application. However, we do not want these aliases to overwrite the original zodios hooks. Therefore, we will try to avoid using names such as get
and delete
as aliases, and instead use alternative solutions such as getById
and deleteById
.
// ✅ Preferred approach
api.useList();
// ❌ Avoid using this approach
api.useQuery('/list');
Further Benefits and Future Planning
Creating API SDK with Zodios
is very easy to maintain, even for backend engineers who are not familiar with TypeScript. With type checking, we can even wrap makeApi
to make the checks more strict. By centralizing these files in a package, it also makes maintaining API standards more convenient, without being interfered by irrelevant content. Zodios
can also directly output OpenAPI documents, which can be browsed through tools like Swagger UI. Moreover, since the API contract is in the repository, it means that it can be committed and even submitted for Pull Request code review like any other code, and it can even generate a changelog.
Currently, Zodios
provides integration with React
and Solid
, as well as Express
. Our next plan is to establish a stub server through @zodios/express
, and maintain test cases in it. We will also provide an interface for each API to switch between test cases or bypass directly to the server maintained by the backend, making it easy for front-end or test engineers to switch between different states when developing or viewing corresponding screens, and reduce the dependency of the front-end on the development efficiency and shared environment of the backend.
Conclusion
We firmly believe that separating the development of API SDK and application development is the way to improve the maintenance of APIs in front-end code. By delegating repetitive and tedious tasks to the underlying layer for unified processing, the pain of using APIs directly in the application will be greatly reduced. Using API SDK can greatly reduce work costs and increase work efficiency. In addition, since maintaining API SDK only requires focusing on API contracts themselves, it is also very easy. Extracting the maintenance of APIs from the application is like “turning one difficult task into two simple tasks” for developers. Through the application verification of Zodios
, we have obtained a good result. Therefore, we are sharing the problems and ideas we have seen in the past here, hoping to provide help to everyone.
Top comments (1)
Thanks! My integration experiece:
TRPC is overhyped by some influential bloggers and you should definitely consider alternatives (there're few) before going ALL-IN with TRCP.