Building a CRUD app with React Query, TypeScript, and Axios

TanStack Query, also known as React Query is described as the missing data-fetching library for web applications. React Query makes fetching, caching, synching, and updating server state a breeze.

React Query comes with an opinionated way of fetching and updating data. React Query makes it easier to manage complex data fetching and caching scenarios in our React applications while providing a simple and intuitive API as compared to traditional state management libraries. Using React Query also has other benefits such as:

  • Error handling - React Query provides robust error handling capabilities. This helps with handling errors that occur during data fetching or mutations.
  • Global state management - React Query provides a central place to manage global state. Making it easy to share data across components and avoid prop drilling
  • Type safety - React Query is built with TypeScript and provides strong type safety. This makes it easier to catch errors and avoid bugs.
  • Optimistic updates - React Query allows us to perform optimistic updates, which means we can update the UI immediately after a mutation is performed, before waiting for the server response.
  • Automatic data re-fetching - React Query can automatically re-fetch data from the server based on several conditions, such as when a mutation is performed, the window is refocused, or a component is mounted.

In this article, we will be learning how to use React Query to fetch and update server data in a React application using Axios.

The complete source code for this article can be found here

Table of Contents

Creating the project

We’ll be using Vite for our web app. Run

$ npm create vite@latest react-query-crud-example

and follow the prompts to select React as the framework and TypeScript as the variant. Change the directory into the project directory and run

$ npm i @tanstack/react-query @tanstack/react-query-devtools axios formik yup react-router-dom json-server
$ npm i -D tailwindcss postcss autoprefixer

The above commands install:

  • React Query
  • React Query dev tools
  • Axios - an HTTP client.
  • formik - a React Form Library.
  • yup - A form data validation library.
  • React Router - a React routing library.
  • JSON Server - a fake REST API. We’ll be using this as the backend server to fetch data from and send data to.
  • Tailwind CSS - A CSS framework we’ll use to style our components.

Run

$ npx tailwindcss init -p

to generate a tailwind.config.cjs file. Replace the contents of tailwind.config.cjs with

/*tailwind.config.cjs*/

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Replace the contents of index.css with

/*index.css*/
@tailwind base;
@tailwind components;
@tailwind utilities;

Setting up the backend server

We’ll be using json-server to create the REST API that we’ll fetch data from. In the root of the project, create a db.json file with the contents

/*db.json*/

    {
        "todos" : [
            {
                "id" : 1,
                "title": "Learn React Query",
                "complete": false
            }
        ]
    }

In the package.json file, add a server script as follows

/*package..json*/

// ...
"scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "server" : "json-server --watch db.json --port 5000" // ADD THIS LINE
  }
// ...

In the command line, run

$ npm run server

This exposes a REST API that we can consume from http://localhost:5000/todos. The API exposes these endpoints

EndpointAction
GET /todosGet all Todos
GET /todos?complete=trueGet all complete Todos
GET /todos/{id}Get a Todo item by its id
POST /todosCreate a new Todo item
PUT /todos/{id}Update an existing Todo
DELETE /todos/{id}Delete a Todo item

Setting up React Query

To be able to use React Query in our application, we need to wrap the QueryClientProvider component around our application entry point. Update the main.tsx file as follows

/*main.tsx*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const queryClient = new QueryClient();

const router = createBrowserRouter([
    {
        path: '/',
        element: <App />
    },
    {
        path : '*',
        element : <h1>Page not found: 404</h1>
    }
]);

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <React.StrictMode>
        <QueryClientProvider client={queryClient}>
            <RouterProvider router={router}/>
            <ReactQueryDevtools />
        </QueryClientProvider>
    </React.StrictMode>
);

React Query works with zero config and can be customized to meet our application requirements. The above sets up React Query with the default options. We can configure our config options by passing a config object into the query client as follows

... 
const queryClient = new QueryClient({
    queries : { // data fetching config
        refetchOnWindowFocus : false,
        refetchOnMount : false,
        retry: false,
        // ... rest of the config
    },
    mutations : { // mutations config

    }
});
// rest of code...

Setting up Axios

Create a new src/api/client.ts file with the following contents

import axios from "axios";

export const client = axios.create({
    baseURL : 'http://localhost:5000/todos',
    headers: {
        'Content-Type': 'application/json'
    }
})

This creates and exports an axios client with the base URL of our server setup.

Fetch all Todos

In the main page of the app, we want to fetch all todo items from the server.

First, create a src/types/todo.types.ts file with the contents

export interface TodoItem {
    id: number;
    title: string;
    complete: boolean;
}

Create a src/hooks/useFetchTodos.ts file with the contents

/*useFetchTodos.ts*/

import { QueryObserverResult, useQuery } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
import { client } from '../api/client';
import { TodoItem } from '../types/todo.types';

const fetchTodos = async (): Promise<AxiosResponse<TodoItem[], any>> => {
    return await client.get<TodoItem[]>('/');
};

export const useFetchTodos = (): QueryObserverResult<TodoItem[], any> => {
    return useQuery<TodoItem[], any>({
        queryFn: async () => {
            const { data } = await fetchTodos();
            return data;
        },
        queryKey: [ 'todos' ]
    });
};

This creates a useFetchTodos hook that fetches data from the API server.

We can use our hook in our App.tsx as follows:

/*App.tsx*/

import { useNavigate } from 'react-router-dom';
import './App.css';
import { useFetchTodos } from './hooks/useFetchTodos';

function App() {
  const { data: todos, isLoading, isError } = useFetchTodos();
  const navigate = useNavigate();
    return (
        <div className="w-full mt-2 items-center bg-gray-100 min-h-screen">
            <h1 className="text-4xl font-bold mb-4">Todo List</h1>
      <button className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-2 ml-2 rounded mb-4" onClick={() => navigate('/add-todo')}>New Todo</button>
      <hr className="mb-2"/>
      {
        isLoading? <h1>Loading...</h1> : isError ? <h1>Error fetching todos</h1> : (
          <ul>
          {todos?.map(todo => {
            return <li className={`mb-2 text-xl ${todo.complete ? 'line-through' : ''}`} key={todo.id}>{todo.title}
              <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 ml-2 rounded">Edit</button>
              <button className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 ml-2 rounded">Delete</button>
            </li>
          })}
          </ul>
        )
      }
        </div>
    );
}

export default App;

Add a new Todo

First, we create a TodoItemForm.tsx component with the contents

import React from 'react';
import { ErrorMessage, Field, Form, Formik } from 'formik';
import * as yup from 'yup';
import { TodoInput, TodoItem } from './types/todo.types';

type Props = {
    action: string;
    todoItem: TodoItem | undefined;
    handleSubmit: (values: TodoInput) => void;
};

export default function TodoItemForm({ todoItem, handleSubmit, action }: Props) {
    return (
        <Formik
            initialValues={{
                title: todoItem? todoItem.title : '',
                complete: todoItem ? todoItem.complete: false
            }}
            validationSchema={yup.object({
                title: yup.string().required('Title is required')
            })}
            onSubmit={(values: TodoInput) => handleSubmit(values)}
        >
            <Form>
                <div className="mb-2">
                    <label htmlFor="title" className="mr-2">
                        Title
                    </label>
                    <Field
                        name="title"
                        type="text"
                        id="title"
                        className="shadow appearance-none border rounded py-1 px-2 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                    />
                    <ErrorMessage name="title" component="span" className="text-red-500" />
                </div>
                <div>
                    <label htmlFor="complete" className="mr-2">
                        Complete
                    </label>
                    <Field name="complete" type="checkbox" id="complete" />
                </div>
                <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 ml-2 rounded">
                    {action}
                </button>
            </Form>
        </Formik>
    );
}

We’re creating a reusable component that will come in handy when we want to edit our Todos

Then we create an src/AddTodo.tsx component with the following contents

import React from 'react';
import { useAddTodo } from './hooks/useAddTodo';
import TodoItemForm from './TodoItemForm';

export default function AddTodo() {
    const { mutate: addTodo } = useAddTodo();

    return (
        <div className="w-full mt-2 items-center bg-gray-100 min-h-screen">
            <h1 className="text-4xl font-bold mb-4">New Todo</h1>
            <TodoItemForm todoItem={undefined} handleSubmit={addTodo} action="Add Todo" />
        </div>
    );
}

We then add the component to our router in main.tsx as follows

import AddTodo from './AddTodo';
// ...

const router = createBrowserRouter([
    {
        path: '/',
        element: <App />
    },
    {
        path: '/add-todo',
        element: <AddTodo/>
    },
    {
        path : '*',
        element : <h1>Page not found: 404</h1>
    }
]);

// ...

We then need to create a src/hooks/useAddTodo.ts hook, the one we reference in our AddTodo component. Create a src/hooks/useAddTodo.ts with the following contents


import { UseBaseMutationResult } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
import { useNavigate } from 'react-router-dom';
import { client } from '../api/client';
import { TodoInput } from '../types/todo.types';

const addTodo = async (todo: TodoInput): Promise<AxiosResponse<TodoInput, any>> => {
    return await client.post<TodoInput>('/', todo);
};

export const useAddTodo = (): UseBaseMutationResult<AxiosResponse<TodoInput, any>, unknown, TodoInput, unknown> => {
    const queryClient = useQueryClient();
    const navigate = useNavigate();
    return useMutation({
        mutationFn: (todo: TodoInput) => addTodo(todo),
        onSuccess: () => {
            queryClient.invalidateQueries([ 'todos' ]);
            navigate('/', { replace: true });
        }
    });
};

Add the following TodoInput definition to src/types/todo.types.ts

export interface TodoInput {
    title: string;
    complete: boolean;
}

Delete a Todo

To delete a todo we first add a src/hooks/useDeleteTodo.ts hook file with the contents

import { UseBaseMutationResult } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
import { client } from '../api/client';

const deleteTodo = async (todoId: number): Promise<AxiosResponse<any, any>> => {
    return await client.delete(`/${todoId}`);
};

export const useDeleteTodo = (): UseBaseMutationResult<AxiosResponse<any, any>, unknown, number, unknown> => {
    const queryClient = useQueryClient();
    return useMutation({
        mutationFn: (todoId: number) => deleteTodo(todoId),
        onSuccess: () => {
            queryClient.invalidateQueries([ 'todos' ]);
        }
    });
};

Then update App.tsx and add an onClick event handler to the delete button as follows

// ...
import {useDeleteTodo} from './hooks/useDeleteTodo'

function App(){
    // ...
    const {mutate : deleteTodo} = useDeleteTodo()

    return (
        //...
        {todos?.map(todo => {
            return 
                <li className={`mb-2 text-xl ${todo.complete ? 'line-through' : ''}`} key={todo.id}>{todo.title}
                    <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 ml-2 rounded">Edit</button>
                    <button 
                        className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 ml-2 rounded" 
                        onClick={() => deleteTodo(todo.id)}>
                        Delete
                    </button>
                </li>
            })}
    )
}

Edit a Todo

We first create an EditTodo.tsx component with the contents


import React from 'react';
import { useParams } from 'react-router-dom';

import { useFetchTodo } from './hooks/useFetchTodo';
import { useEditTodo } from './hooks/useEditTodo';
import TodoItemForm from './TodoItemForm';


export default function EditTodo() {
    const { id } = useParams();
    const { data: todoItem, isLoading } = useFetchTodo(id ? parseInt(id) : 0);

    const { mutate: editTodo } = useEditTodo(id ? parseInt(id) : 0);
    return (
        <div className="w-full mt-2 items-center bg-gray-100 min-h-screen">
            <h1 className="text-4xl font-bold mb-4">Edit Todo</h1>
            {isLoading? <h1>Fetching todo...</h1> : <TodoItemForm todoItem={todoItem} handleSubmit={editTodo} action="Edit Todo"/> }
        </div>
    );
}

We then add the component to our router in main.tsx as follows

import EditTodo from './EditTodo';
// ...

const router = createBrowserRouter([
    {
        path: '/',
        element: <App />
    },
    {
        path: '/add-todo',
        element: <AddTodo/>
    },
    {
        path: '/edit-todo/:id',
        element: <EditTodo />
    },
    {
        path : '*',
        element : <h1>Page not found: 404</h1>
    }
]);

// ...

Then update App.tsx and add an onClick event handler to the edit button as follows

{isLoading? <h1>Loading...</h1> : isError ? <h1>Error fetching todos</h1> : (
    <ul>
    {todos?.map(todo => {
        return <li className={`mb-2 text-xl ${todo.complete ? 'line-through' : ''}`} key={todo.id}>{todo.title}
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 ml-2 rounded" onClick={() => navigate(`/edit-todo/${todo.id}`)}>Edit</button>
        <button className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 ml-2 rounded" onClick={() => deleteTodo(todo.id)}>Delete</button>
        </li>
    })}
    </ul>
)}

We then create two hooks, a useFetchTodo hook that allows us to fetch data for a single Todo item and a useEditTodo hook that allows us to edit a Todo item

Create a src/hooks/useFetchTodo.ts file with the following contents

import { QueryObserverResult, useQuery } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
import { client } from '../api/client';
import { TodoItem } from '../types/todo.types';

const fetchTodo = async (todoId: number): Promise<AxiosResponse<TodoItem, any>> => {
    return await client.get<TodoItem>(`/${todoId}`);
};

export const useFetchTodo = (todoId: number): QueryObserverResult<TodoItem, any> => {
    return useQuery<TodoItem, any>({
        queryFn: async () => {
            const { data } = await fetchTodo(todoId);
            return data;
        },
        queryKey: [ 'todo', todoId ]
    });
};

Then add a src/hooks/useEditTodo.ts file with the following contents

import { UseBaseMutationResult } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
import { useNavigate } from 'react-router-dom';
import { client } from '../api/client';
import { TodoInput } from '../types/todo.types';

const editTodo = async (todoId: number, todo: TodoInput): Promise<AxiosResponse<TodoInput, any>> => {
    return await client.put<TodoInput>(`/${todoId}`, todo);
};

export const useEditTodo = (
    todoId: number
): UseBaseMutationResult<AxiosResponse<TodoInput, any>, unknown, TodoInput, unknown> => {
    const queryClient = useQueryClient();
    const navigate = useNavigate();
    return useMutation({
        mutationFn: (todo: TodoInput) => editTodo(todoId, todo),
        onSuccess: () => {
            queryClient.invalidateQueries([ 'todos' ]);
            navigate('/', { replace: true });
        }
    });
};

Conclusion

React Query is a powerful library that makes data fetching and state management in React applications easy and efficient. It provides a simple and intuitive API for managing server state and has features such as caching, re-fetching, polling, and more.

In this article we saw how to create a new React application, set up React Query, and fetch data from a REST API using Axios. There is a lot of React Query that we haven’t touched on in this article, such as configuration, testing, etc, as it is outside the scope of this article.


typescriptvitereact-query

2211 Words

Mar 20, 2023