DEV Community

Cover image for From Tedious to Simple: Reshaping Your API Integration Experience with Zodios
Vi Pro
Vi Pro

Posted on • Edited on

From Tedious to Simple: Reshaping Your API Integration Experience with Zodios

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]);
Enter fullscreen mode Exit fullscreen mode

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}`;
Enter fullscreen mode Exit fullscreen mode

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)}`;
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 integrate TanStack Query or other similar tools.
  • It must be possible to define Date and convert ISO8601 in the response to Date 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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  }),
},
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.

Reference

This article was assisted by ChatGPT and the image was also generated through Stable Diffusion


This article was assisted by ChatGPT and the image was also generated through Stable Diffusion

Top comments (1)

Collapse
 
ivankleshnin profile image
Ivan Kleshnin • Edited

Thanks! My integration experiece:

  • TRPC – very hard, took 1 day. Expected a lightweight library, got a bulky framework, not necessary compatible with latest React and NextJS ecosystem...
  • Zodios – very easy, took 1 hour. It's everything I expected TRPC to be.

TRPC is overhyped by some influential bloggers and you should definitely consider alternatives (there're few) before going ALL-IN with TRCP.