DEV Community

Cover image for Typesafe API with TS-Rest, React Query
Midoriya
Midoriya

Posted on

Typesafe API with TS-Rest, React Query

Hi, today I'll introduce you to a library called ts-rest. A library that supports API organization while integrating with the typesafe API.

What is TS-Rest ?

In short, TS-Rest is a library help you define your API and give you an end to end safe API.

In addition, TS-Rest can help you define API normally (you can define Put, Patch, Delete, Get, Post Method).

If you work with a Third Party API. TS-Rest is a good choice to leverages TypeScript's type system to provide strong typing for API endpoints, request/response payloads, and data models. This ensures that you're working with the correct data types and minimizes the risk of runtime errors due to incorrect data handling.

How to use TS-Rest ?

In this part, I will be creating a simple Todo app to introduce TS-Rest on the client side.

In this project, I will use CRA for initilizing my React app.
These are libraries I will use in this project :

  • React Query
  • @ts-rest/core for creating contract,router
  • @ts-rest/react-query

So let's get started 🚀

Installation

Before we're going to project please make sure you have installed @ts-rest library and react-query library.

yarn add @ts-rest/react-query @tanstack/react-query @ts-rest/core

Setup fake API server

In this project I will use json-server for creating fake API server.

Steps:

  1. Install json-server : yarn add json-server
  2. Next create a db.json in your project
{
  "data": [
    {
      "title": "Todo 1",
      "id": 1
    },
    {
      "title": "todo 2",
      "id": 2
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  1. In package.json add this script:
scripts : {
  .....
  "server": "json-server --watch db.json --port 5000"
  .....
}
Enter fullscreen mode Exit fullscreen mode

So let's run yarn server. This will start out server in this project.

Setup ts-rest router

First create src/constants/index.ts for defining some API Route and Server URL.

src/constants/index.ts

export const SERVER_URL = 'http://localhost:5000/data';

export const API_ROUTE = {
  GET: '/',
  CREATE: '/',
  UPDATE: '/:id',
  DELETE: '/:id',
};
Enter fullscreen mode Exit fullscreen mode

Next, create lib/ts-rest folder in your src directory. Inside that we will create two file :

//lib/ts-rest/router.ts

import { initContract } from '@ts-rest/core';
import { API_ROUTE } from '../../constants';
import { z } from 'zod';

const c = initContract();

export const router = c.router({
  getAllTodo: {
    method: 'GET',
    path: API_ROUTE.GET,
    responses: {
      200: z.array(
        z.object({
          id: z.number(),
          title: z.string(),
        }),
      ),
    },
  },
  createTodo: {
    method: 'POST',
    path: API_ROUTE.CREATE,
    responses: {
      201: z.object({
        id: z.number(),
        title: z.string(),
      }),
    },
    body: z.object({
      title: z.string(),
    }),
  },
  updateTodo: {
    method: 'PATCH',
    path: API_ROUTE.UPDATE,
    responses: {
      204: z.object({
        id: z.number(),
        title: z.string(),
      }),
    },
    pathParams: z.object({ id: z.number() }).required(),
    body: z.object({
      title: z.string(),
    }),
  },
  deleteTodo: {
    method: 'DELETE',
    path: API_ROUTE.DELETE,
    responses: {
      200: z.object({
        id: z.number(),
        title: z.string(),
      }),
    },
    pathParams: z.object({ id: z.number() }).required(),
    body: z.undefined(),
  },
});
Enter fullscreen mode Exit fullscreen mode

This code is simply the define route of API.
For each property in router we have :

  • method : Definition of that API's method
  • path : Definition of that API's path
  • reponses : Definition of that API's return data type (for example : in getAllTodo's response when fetch data success. It's will return an array have type {id:number;title:string}[].
  • pathParams : Definition of that API's path param type. If that API Route uses path params, you must define this. For example in deleteTodo's path I have declared the path is /:id. So I need create a pathParams like this z.object({ id: z.number() }).required(). (If you define your path like this : /:idPost your pathParams must be z.object({ idPost: z.number() }).required()
  • body : Definition of that API's body type.

Finally in lib/ts-rest/client-provider ,let create a clientProvider to query and mutate data from server.

import { initQueryClient } from '@ts-rest/react-query';
import { router } from './router';
import { SERVER_URL } from '../../constants/';

export const clientProvider = initQueryClient(router, {
  baseUrl: SERVER_URL,
  baseHeaders: {},
});
Enter fullscreen mode Exit fullscreen mode

If you want custom your api (override). You can use api property in initQueryClient's options. Please refer here

So that's all. We have configured our TS-Rest client provider completely.

Setup react-query and create some component

First in App.tsx let's setup react-query provider.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';

const queryClient = new QueryClient()

export const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <div className='flex flex-col items-center py-10'>
        <InputTodo />
        <TodoList />
        <Toaster />
      </div>
    </QueryClientProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Let's create TodoList,InputTodo component

src/components/TodoList.tsx

import { clientProvider } from "../lib/ts-rest/client-provider"
import TodoItem from "./TodoItem"

const TodoList = () => {
  const { data, isLoading, } = clientProvider.getAllTodo.useQuery(['GET_ALL_TODO'])

  return (
    isLoading ? <p>Loading...</p> :
      <div className="flex flex-col">
        {data?.body.map(todoItem => <TodoItem {...todoItem} key={todoItem.id} />)}
      </div>
  )
}

export default TodoList
Enter fullscreen mode Exit fullscreen mode
src/components/TodoList.tsx

import { clientProvider } from "../lib/ts-rest/client-provider"
import TodoItem from "./TodoItem"

const TodoList = () => {
  const { data, isLoading, } = clientProvider.getAllTodo.useQuery(['GET_ALL_TODO'])

  return (
    isLoading ? <p>Loading...</p> :
      <div className="flex flex-col">
        {data?.body.map(todoItem => <TodoItem {...todoItem} key={todoItem.id} />)}
      </div>
  )
}

export default TodoList
Enter fullscreen mode Exit fullscreen mode

In TodoList component we have use clientProvider to query the data from server. Thanks to Ts-Rest, every time call clientProvider.getAllTodo you will ensure the Safe API, making it easier for maintaining and debugging.

src/components/TodoItem.tsx

const TodoItem = ({ id, title }: { id: number, title: string }) => {
  const { mutateAsync } = clientProvider.deleteTodo.useMutation();
  const queryClient = useQueryClient()

  const onDelete = async () => {
    try {
      await mutateAsync({
        params: { id }
      })
      queryClient.invalidateQueries({ queryKey: ['GET_ALL_TODO'] })
      toast.success("Delete success")
    } catch (error) {
      toast.error(JSON.stringify(error))
    }
  } 

  return (
    <div>
      <span>{title}</span>
      <div className="flex gap-3">
        <button onClick={onDelete}>
          Delete
        </button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Also this is an example of using useMutation with TS-Rest. It helps you control the type of pathParams input.

Finally let's create a InputTodo component.

const InputTodo = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const { mutateAsync } = clientProvider.createTodo.useMutation()
  const queryClient = useQueryClient()

  const onAddTodo = async () => {
    try {
      if (inputRef.current) {
        await mutateAsync({
          body: {
            title: inputRef.current?.value
          }
        })
        toast.success('Add todo success');
        queryClient.invalidateQueries({ queryKey: ['GET_ALL_TODO'] })

      }
    } catch (error) {
      toast(JSON.stringify(error))
    }
  }

  return (
    <div>
      <input ref={inputRef} placeholder="Add todo..." />
      <button
        onClick={onAddTodo}>Add</button>
    </div>
  )
}

export default InputTodo
Enter fullscreen mode Exit fullscreen mode

In addition to pathParams TS-Rest can also type safe for body input.

So's that all 🙏 about demo project.

Summary

TS-Rest is great in helping you manage your API Design and easy to maintain.

However, it still has some points I am not satisfied with Ts-Rest.

  • It is still a library being developed so the community is not much.
  • Document has not been written clearly (this is my opinion).
  • Currently, it is still not used with React-Query V5. So I hope they will update in the next time.

Thanks for reading. 👏

Repo link : https://github.com/quangnmwork/ts-rest-tutorial

Top comments (0)