DEV Community

solomonfrank
solomonfrank

Posted on • Updated on

React query with typescript and vite

At the end of this article, you will understand

  • What React-query is, why should you care about it, and when to use it.
  • Whether it's a replacement for client-side state management like redux, zustand, and others.
  • How to use react-query.

Let's get started.
Why should I care about react-query
Data fetching in react is usually done in the useEffect which involves a lot of boilerplate and has its downsides:

  • Because effect gets to run after the component is rendered the fetching of data only starts once the rendering process is completed this delays the early fetching of data.
  • Effect creates a network waterfall.
  • Data fetching will be triggered every time your components unmount and mount unless you manually set up a data caching mechanism on the client which react-query provides for you out of the box.

React-query solves the downside listed above out-of-the-box without any complexity and also helps in data synchronization, deduplicating multiple requests for the same data into a single request. In a more general term, React-query is a library for data-fetching, data synchronization, and data caching.

Does React-query replace redux, zustand, and others?
For anyone trying to use this library for the first time, this is usually the question. React query helps to keep your server/remote state in sync with the client. An example could be fetching a list of users from the server which is asynchronous and keeping it in sync with the client. React-query helps us to manage this flow efficiently and in a very optimized manner. If your application requires moving parts where you need to track different client states then you could use any of the client state manager and react-query server state for remote state.
Having cleared this, congrats for making it this far, lets sip some wine and continue...

Glass of wine

We are going to build our simple app using vite to scaffold our React typescript project.

Firstly

npm create vite@latest react-query --template react-ts
Enter fullscreen mode Exit fullscreen mode

to create a react project with typescript template
cd into react-query then run npm i

lets download all the dependencies we need

npm i @tanstack/react-query

npm i -D @tanstack/react-query-devtools
Enter fullscreen mode Exit fullscreen mode

let's be a little bit organised by creating a lib folder and creating reactQuery.ts file

mkdir src/lib
cd src/lib
touch reactQuery.ts
Enter fullscreen mode Exit fullscreen mode

inside reactQuery.ts add the content below

import { DefaultOptions, QueryClient } from "@tanstack/react-query";

const queryConfig: DefaultOptions = {
  queries: {
    refetchOnWindowFocus: import.meta.env.PROD, // *1
    retry: false,  // *2
    staleTime: 3000,  // line 3
  },
};

export const queryClient = new QueryClient({ defaultOptions: queryConfig });
Enter fullscreen mode Exit fullscreen mode

On line *1 we are disabling the default refetchonWindowFocus to prevent auto fetching of data every time the window is on focus. In production its value is meant to be true; re-fetching will happen in the background to keep our state in sync or fresh.
NB: If refetchonWindowFocus is not disabled on development, you will continuously see data fetching when you open the browser development tool.

Line *2: retry; every failed query is retried a couple of times set on the config before the final error is shown. We are disabling the retry of queries.

Line 3 staleTime means how long does it take before a refetch happens. We could say is a 'freshness boundary', within this period any query instances with the same key will read from the cache because the data is still considered fresh. Once the duration set is reached the query is considered stale. Stale queries are automatically re-fetched in the background.

Let's write our first query. Update the main.tsx with
QueryClientProvider

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { queryClient } from "./lib/reactQuery.ts";
import { QueryClientProvider } from "@tanstack/react-query";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
    {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Just like react context provider the QueryClientProvider makes the useQueryClient available to its children and ReactQueryDevtools allows us to inspect stored queries and we are enabling it only on development mode(import.meta.env.DEV)

Next,

mkdir src/api   // to create api folder
cd src/api
touch getProduct.ts // to create getProduct file, then paste the code below
Enter fullscreen mode Exit fullscreen mode

our first query instance:

import { useQuery } from "@tanstack/react-query";
import { FetchError, fetchJSON } from "../lib/fetchJson";

export type Product = {
  id: string;
  description: string;
  title: string;
  thumbnail: string;
  brand: string;
  category: string;
  price: string;
  rating: number;
};

export type ProductResponse = {
  products: Product[];
  total: number;
};

export const getProductHandler = async ():Promise<Product[]> => {
  const response = await fetchJSON<ProductResponse>(
    "https://dummyjson.com/products"
  );
  return response.products;
};
export type queryConfigOption = {
  filter?: Record<string, string>;
  enabled?: boolean;
};
export const useGetProduct = ({ enabled=true }: queryConfigOption) => {
  return useQuery<Product[], FetchError>({
    queryFn: () => getProductHandler(),  // **1
    queryKey: ["products"], // **2
    enabled, // **3
  });
};
Enter fullscreen mode Exit fullscreen mode

Line **1, queryFn is expecting a function that returns a promise with a well-defined type.
Line **2 queryKey we choose a key to use for the query instance. it is important to remember that when the queryKey value changes it triggers an automatic refetch.
on line **3 enabled by default is true which means the fetching is triggered immediately. we can use the enabled property to conditionally trigger if we want fetching to happen instantly. That means we could also set its value to be false until a certain requirement is met before we can trigger the fetching by setting enabled: true.

Next, let's make use of the useGetProduct hook we created

import "./App.css";
import { useGetProduct } from "./api/getProduct";
import { FetchError } from "./lib/fetchJson";

function App() {
  const productQuery = useGetProduct ({}); // *** 1

  if (productQuery.isLoading) {
    return <div>Loading...</div>;
  }

  if (productQuery.isSuccess && !productQuery.data?.length) {
    return <div>No Data</div>;
  }

  if (productQuery.isError) {
    return <div>{errorMessage(productQuery?.error)}</div>;
  }

  return (
    <section className="container">
      <div>
        <div className="product-list">
          {productQuery.data.map((product) => (
            <div className="product-list-item" key={product.id}>
              <img src={product.thumbnail} />
              <h4>{product.title}</h4>
              <p>{product.description}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

const errorMessage = (error: Error) => {
  if (error instanceof FetchError) {
    return `${error.statusCode} : ${error.message} `;
  }
  return error.message;
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Line ***1 we could pass different options to the hooks, e.g. it could be dynamically setting enabled and also passing different filter parameters. Our query will be cached till the stale time.

To make any changes(or delete) on the server we could use the useMutation hook and then invalidate the query on success. Let's demonstrate it.
inside api folder create product createProduct.ts

import { useMutation } from "@tanstack/react-query";
import { fetchJSON } from "../lib/fetchJson";
import { queryClient } from "../lib/reactQuery";
import { Product } from "./getProduct";

export type CreateProductReq = {
  description: string;
  title: string;
  brand: string;
  price: string;
};

export const createProduct = async (data: CreateProductReq) => {
  const response = await fetchJSON<Product>(
    "https://dummyjson.com/products/add",
    {
      method: "Post",
      body: JSON.stringify(data),
      headers: {
        "Content-Type": "application/json",
      },
    }
  );
  return response;
};

export type queryConfigOption = {
  filter?: Record<string, string>;
  enabled?: boolean;
};

export const useCreateProduct = () => {
  return useMutation({ // **** 1
    mutationFn: createProduct, // **** 2
    onMutate: async (newProduct) => { // **** 3
      await queryClient.cancelQueries({ queryKey: ["products"] }); // **** 4

      const optimisticProduct = { id: Date.now().toString(), ...newProduct }; // **** 5

      const previousProduct = queryClient.getQueryData<Product[]>(["products"]); // **** 6

      queryClient.setQueryData(
        ["products"],
        [optimisticProduct, ...(previousProduct || [])]
      ); // **** 7

      return { previousProduct }; // **** 8
    },
    onSuccess: () => {
      queryClient.invalidateQueries(["products"]); // **** 9
      // showNotification("Product created successfully")
    },

    onError: (error, payload, context) => {
      if (context?.previousProduct) {  // **** 10
        queryClient.setQueryData(["products"], context?.previousProduct);
      }
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Let's walk through the lines of code. Any desired changes on the server are done using the useMutation hook as shown on line **** 1. useMutation provides us with different helper functions that we could use to make any changes during the mutation lifecycles.
Line **** 2 mutationFn references our server mutating function which is asynchronous.
Line **** 3 onMutate allows us to make changes to our cache query while waiting for the mutation lifecycle to finish. It provides us with a function handler to perform something like an optimistic update.
inside the onMutate handler.
Line **4 and line **5, we are canceling all current product queries and composing a new product object for our optimistic update using the newProduct variable which is our payload respectively.

Line **** 6, we are retrieving the already stored products query with queryKey products which will be used for a rollback in case of error.
Line **** 7, we are updating the product query with the newly composed product object. This implies that the user will see an updated product list with a new product while an update is still happening on the server, if there is an error on the server the product list is rolled back.

Line **** 8, previousProduct is passed to the context which will be used for rollback in case of error.

Line **** 9, if the request was successful, onSuccess gets to run and the existing products query is invalidated which triggers an automatic refetch. Once the refetch is done our product query is updated with fresh data from the server.
if the request to the server fails onError gets to execute and we are rolling back the optimisitically updated products query with previousProduct that was passed to the context on line **** 8.
Let's make use of the useCreateProduct hook we created by updating our App.tsx

import "./App.css";
import { useGetProduct } from "./api/getProduct";
import { FetchError } from "./lib/fetchJson";
import { useInput } from "./hook/useInput";
import { useCreateProduct } from "./api/createProduct";

function App() {
  const { onChange, values } = useInput({
    title: "",
    description: "",
    price: "",
    brand: "",
  });

  const productQuery = useGetProduct({});
  const createProductMutation = useCreateProduct(); // ***** 1

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await createProductMutation.mutateAsync(values); // ***** 2
  };

  if (productQuery.isLoading) {
    return <div>Loading...</div>;
  }

  if (productQuery.isSuccess && !productQuery.data?.length) {
    return <div>No Data</div>;
  }

  if (productQuery.isError) {
    return <div>{errorMessage(productQuery?.error)}</div>;
  }

  return (
    <section className="container">
      <div>
        <div className="wrapper">
          <form onSubmit={handleSubmit}>
            <div className="input-container">
              <div className="input-field">
                <label htmlFor="title">Title</label>
                <input
                  name="title"
                  type="text"
                  id="title"
                  value={values.title}
                  onChange={onChange}
                />
              </div>
              <div className="input-field">
                <label htmlFor="description">Description</label>
                <input
                  name="description"
                  type="text"
                  id="description"
                  value={values.description}
                  onChange={onChange}
                />
              </div>
              <div className="input-field">
                <label htmlFor="price">Price</label>
                <input
                  name="price"
                  type="text"
                  id="prices"
                  value={values.price}
                  onChange={onChange}
                />
              </div>
              <div className="input-field">
                <label htmlFor="brand">Brand</label>
                <input
                  name="brand"
                  type="text"
                  id="brand"
                  value={values.brand}
                  onChange={onChange}
                />
              </div>
              <div className="submit-wrap">
                <button type="submit">
                  {createProductMutation.isLoading ? "Submitting" : "Add product"}
                </button>
              </div>
            </div>
          </form>
        </div>

        <div className="product-list">
          {productQuery.data.map((item) => (
            <div className="product-list-item" key={item.id}>
              <img src={item.thumbnail} />
              <h4>{item.title}</h4>
              <p>{item.description}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

const errorMessage = (error: Error) => {
  if (error instanceof FetchError) {
    return `${error.statusCode} : ${error.message} `;
  }
  return error.message;
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Line ***** 1 we are calling the useCreateProduct which returns an object that contains a mutation handler mutate and mutateAsync for updating the server state. it is important to remember that mutate and mutateAsync are asynchronous.
On line ***** 2 we are triggering the mutation handler mutateAsync with the desired payload.
NB: Updating and deleting of records could have the same flow.

Other helper functions

// lib/fetchJson.ts
export class FetchError extends Error {
  response: Response;
  statusCode: number;
  constructor({
    message,
    response,
    statusCode,
  }: {
    message: string;
    response: Response;
    statusCode: number;
  }) {
    super(message);

    this.name = "FetchError";
    this.statusCode = statusCode;
    this.response = response;
    this.message = message;
  }
}
export const fetchJSON = async <JSON = unknown>(
  input: RequestInfo,
  init?: RequestInit
): Promise<JSON> => {
  const response = await fetch(input, init);

  const data = await response.json();

  if (response.ok) {
    return data;
  }

  throw new FetchError({
    message: response.statusText,
    response,
    statusCode: response.status,
  });
};

Enter fullscreen mode Exit fullscreen mode
// hook/userInput.ts
import { useState } from "react";
import { CreateProductReq } from "../api/createProduct";

export const useInput = (defaultValue: CreateProductReq) => {
  const [values, setValues] = useState(defaultValue);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValues((prev) => ({ ...prev, [e.target.name]: e.target.value }));
  };

  return { onChange, values };
};

Enter fullscreen mode Exit fullscreen mode

Conclusion:
React-query saves us a lot of boilerplate required for managing server state and caching of data. it also tries to keep our client state in sync with our server state. With React-query setup in our system, all asynchronous states all automatically managed and where are left with little client state to manage with React context or zustand if needed.

References
https://tanstack.com/query/v4/docs/react/typescript
https://tanstack.com/query/v4/docs/react/guides/queries
https://tanstack.com/query/v4/docs/react/guides/mutations

Github link

Top comments (0)