Data Sharing in React Applications (PART 3): Tanstack Query aka React Query.

Data Sharing in React Applications (PART 3): Tanstack Query aka React Query.

Featured on Hashnode

So far, we have covered two data-sharing techniques in React Applications, via making redundant network requests and using React's inbuilt Context API.

In the final part of this three-part series, we'll explore a third alternative, Tanstack Query.

Tanstack Query is a powerful library equipped with many features under the hood to manage fetching, caching, and synchronization of data across components. It has become the de facto solution for seamless data management in React applications lately, as it abstracts away the intricacies of data management, allowing developers to focus on building performant applications.

Getting Started

We'll begin by installing both Tanstack Query and Axios libraries. Note that Tanstack Query can be used with the native fetch API but its seamless integration and cleaner syntax when used with Axios is why I am using the latter.

npm i @tanstack/react-query axios

The first step in setting up Tanstack Query is wrapping our entire application with the QueryClientProvider component.

The QueryClientProvider acts as a context provider (it's built on top of the native Context API, which we covered in the previous article), making the query client accessible to any component it wraps.

The query client, initiated with const queryClient = new QueryClient() is the starting point for integrating Tanstack Query into your React application. It acts as a centralized manager for tracking and handling your application's state and data.

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
        <QueryClientProvider client={queryClient}>
            <App />
        </QueryClientProvider>
    </React.StrictMode>
);

When we wrap our entire application with QueryClientProvider, we ensure that every component within the application tree has access to the same instance of the query client thus maintaining a consistent state across our application for efficient data fetching and caching.

Queries

A query is a function used to fetch and manage asynchronous data within the React application. Querying of data from an API endpoint using Tanstack Query is done via the useQuery hook.

The hook should be invoked with an object which accepts at least two configuration options:

  1. A query key: This is a unique key we provide that Tanstack Query will use under the hood for refetching, caching, and sharing our queries throughout the application. We'll see later how exactly this works in the section on mutations.

  2. A query function: This is a function that will fetch our data. This function should return a promise that resolves with the fetched data.

Creating a custom query hook.

As in the previous articles, we'll define our data-fetching logic inside a custom hook.

Let us start by renaming the useFetch file (if you are following from the last article) to a more descriptive useFetchSuperheroes. Note that this renaming step is not mandatory.

Inside it, we declare the function that will fetch our data:

import axios from 'axios';

async function fetchSuperheroes(endpoint) {
    try {
        const response = await axios.get(`http://localhost:3000/${endpoint}`);
        return response.data;
    } catch (error) {
        throw new Error(error.message);
    }
}

Next, we define in the same file our custom hook that will utilize Tanstack Query's useQuery hook to handle the data once it has been queried from the API:

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

 {/*same query function as before*/}

export const useFetchSuperheroes = (endpoint) => {
    return useQuery({
        queryKey: ['superheroes', endpoint],
        queryFn: () => fetchSuperheroes(endpoint),
    });
};

This hook encapsulates the logic for fetching superheroes data from the endpoint.

queryKey explained.

The queryKey property is used to uniquely identify each query. It is essential in managing caching, deduplication, and data retrieval within the Tanstack Query ecosystem. Here are a few things to note about queryKey property:

  1. Unique Identifier: The queryKey serves as a unique identifier for each query. It helps Tanstack Query distinguish between different queries and manage their caching and retrieval independently. By providing a unique identifier, we ensure that each query has its own cache entry and doesn't interfere with other queries.

  2. Array Structure: The queryKey is typically specified as an array. Using an array allows for flexibility in defining the key based on multiple parameters or dynamic values. Each element of the array contributes to the uniqueness of the query key.

  3. Dynamic Values: The queryKey can include dynamic values such as parameters or variables that affect the outcome of the query. This enables us to create dynamic queries that respond to changes in user input, application state, or other factors. For example, in our code snippet, the endpoint parameter is included in the queryKey array, ensuring that each endpoint has its unique cache entry.

  4. Cache Management: Tanstack Query utilizes the queryKey to manage caching efficiently. When a query is executed, Tanstack Query checks its cache using the queryKey to determine if the requested data is already available. If the data is cached and still considered fresh according to the cache policy, Tanstack Query returns the cached data without making a network request. This is important in improving performance and reducing unnecessary data fetching.

  5. Dependency Tracking: Tanstack Query tracks dependencies based on the queryKey to ensure that queries are automatically updated when their dependencies change. This enables reactive behaviour, where queries automatically refresh and update in response to changes in data dependencies or external triggers.

The queryFn configuration property is a callback function that invokes the asynchronous function we defined earlier that queries the API endpoint.

Consuming our custom query hook.

That is all there is to writing a query, although we'll return to it and explore more configuration settings that will elevate the hook's functionality.

Before we use the hook in our code, let us first remove the Context wrapping our App.jsx component (for those coming from the previous article).

We go from this:

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';
import SuperheroesContextProvider from './contexts/SuperheroesContextProvider';

const App = () => {
    return (
        <div className="app">
            <SuperheroesContextProvider>
                <SuperheroCount />
                <div>
                    <SuperheroList />
                    <AddSuperhero />
                </div>
            </SuperheroesContextProvider>
        </div>
    );
};

export default App;

To this:

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';

const App = () => {
    return (
        <div className="app">
                <SuperheroCount />
                <div>
                    <SuperheroList />
                    <AddSuperhero />
                </div>
        </div>
    );
};

export default App;

Now we replace the useContext invocation in both our SuperheroCount and SuperheroList components with our custom query hook.

import { useFetchSuperheroes } from '../hooks/useFetchSuperheroes';

const SuperheroCount = () => {
    const { data: superheroes } = useFetchSuperheroes('superheroes');

    return <div>Total Superheroes: {superheroes?.length} </div>;
};

export default SuperheroCount;
const SuperheroList = () => {
    const { data: superheroes } = useFetchSuperheroes('superheroes');

    return (
        <table>
            {/*same as before*/}
        </table>
    );
};

export default SuperheroList;

Our page still works as expected, getting the list and count of superheroes:

Remember, although we are invoking the data-fetching hook twice, only one network request is made because Tanstack Query maintains a cache of query results by default. When the query hook is executed in the SuperheroCount component, Tanstack Query first checks its cache to see if the data in the SuperheroList component is already available and if it can be reused in the SuperheroCount component. If the data is present in the cache and it's not stale according to the configured caching policy (check the BONUS section on configuring the caching policy), Tanstack Query returns the cached data without making an additional network request.

Mutations

Alongside queries, the other important pillar of Tanstack Query is mutations.

While queries read data from the API endpoint, mutations perform actions that create, update, or delete data.

Used together correctly, the two communicate seamlessly allowing queries to know when the data has since mutated (thus stale) and make a fresh network request. This ensures our application is always running on up-to-date data.

Let us refactor our previous function for adding a new superhero to use Tanstack Query so that we unlock this performance benefit.

This is the logic we have currently implemented in the AddSuperhero.jsx component:

const AddSuperhero = () => {
    const handleSubmit = (e) => {
        e.preventDefault();

        const formData = new FormData(e.target);
        const data = Object.fromEntries(formData);

        fetch('http://localhost:3000/superheroes', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(data),
        });
    };
    return (
        <form onSubmit={handleSubmit}>
            {/*table elements*/}
        </form>
    );
};

export default AddSuperhero;

Creating a custom mutation hook.

We want to abstract the fetch API to a custom hook which we'll define in a file called useAddSuperheroMutation. In it, we'll handle mutation using Tanstack Query and Axios.

import axios from 'axios';

async function addSuperhero(superhero) {
    try {
        const response = await axios.post(
            'http://localhost:3000/superheroes',
            superhero
        );

        return response.data;
    } catch (error) {
        throw new Error(error);
    }
}

Like before, we define our custom hook just below this asynchronous function.

Mutation of data using Tanstack Query is done using the useMutation hook. The hook should be invoked with an object which accepts at least one configuration option, the mutation function, which is a callback that will invoke the asynchronous function above:

import { useMutation } from '@tanstack/react-query';
import axios from 'axios';

{/*same mutation function as before*/}

export function useAddSuperheroMutation() {
    return useMutation({
        mutationFn: addSuperhero,
    });
}

Consuming our custom mutation hook.

That's just about all there is to writing a basic mutation.

In our consuming component, AddSuperhero, we call our mutation hook and chain to it the mutateAsync method. At this point, our component should look like this:

import { useAddSuperheroMutation } from '../hooks/useAddSuperheroMutation';

const AddSuperhero = () => {
    const addSuperheroMutation = useAddSuperheroMutation();

    const handleSubmit = async (e) => {
        e.preventDefault();

        const formData = new FormData(e.target);
        const data = Object.fromEntries(formData);

        try {
            await addSuperheroMutation.mutateAsync(data);
            e.target.reset();
        } catch (error) {
            console.error('Failed to add superhero:', error);
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            {/*table elements*/}
        </form>
    );
};

export default AddSuperhero;

With this implementation done, we can try adding a new superhero:

At first, it seems as though nothing happens, but when we reload the page, we see the superheroes table and count updated. The mutation works, but the user experience is suboptimal if our users will need to do a hard reload of a page to see whether a mutation was successful.

To improve on this, we go back to the useMutation configuration object and add an onSuccess method.

The onSuccess property is a callback function that allows you to specify a function to be executed when a mutation operation successfully completes without encountering any errors.

In our case, we want the previous superheroes query to be refreshed following a successful mutation, prompting a new network request. This is where we see the use of something we haven't yet interacted with directly to this point.

That is the queryClient instance which we passed in the QueryClientProvider component wrapping our entire application. Remember, the query client acts as a store for all the data we have queried in our application. We need to access it using another hook called useQueryClient.

Once we do so, we retrieve from it the query identified by the superheroes query key and invalidate it, which subsequently triggers a new network request to the API endpoint associated with that key any time the data changes.

This is the syntax for performing this operation:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

async function addSuperhero(superhero) {
     {/*same as before*/}
}

export function useAddSuperheroMutation() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: addSuperhero,
        onSuccess: () => {
            queryClient.invalidateQueries('superheroes');
        },
    });
}

Now when we add a new superhero, it displays on the table without the need for a reload.

Bonus: Extending the powers of a Query by configuring the caching policy.

We earlier saw the basic syntax of a query.

However, Tanstack Query allows us to pass additional configuration options to our queries to determine how frequently caching is done.

These are a few optional configuration options from the Tanstack Query documentation which we can pass into the useQuery object.

  • refetchOnWindowFocus: boolean | "always" - If set to true, the query will refetch on window focus if the data is stale.

  • staleTime: number | Infinity - The time in milliseconds after data is considered stale. This value only applies to the hook it is defined on.

  • retry: boolean | number | (failureCount) => boolean - If false, failed queries will not retry by default. If true, failed queries will retry infinitely. If set to a number, e.g. 5, failed queries will retry until the failed query count meets that number.

  • refetchInterval: number | false | ((query) => number | false | undefined) - If set to a number, all queries will continuously refetch at this frequency in milliseconds. If set to a function, the function will be executed with the query to compute a frequency.

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

 {/*same query function as before*/}

export const useFetchSuperheroes = (endpoint) => {
    return useQuery({
        queryKey: ['superheroes', endpoint],
        queryFn: () => fetchSuperheroes(endpoint),
        refetchOnWindowFocus: true,
        staleTime: 2000,
    });
};

Conclusion

As we wind up on data-sharing solutions for React applications, we see Tanstack Query as the most superior option to the other approaches we covered.

Tanstack Query offers a multitude of advantages over traditional approaches like the Context API. Some key ones in summary are:

  1. Performance and Efficiency: Centralized caching, automatic data synchronization, and optimized data fetching strategies ensure superior performance and efficiency, minimizing unnecessary network requests and enhancing application responsiveness.

  2. Simplicity and Ease of Use: With intuitive APIs and seamless integration with React components, Tanstack Query simplifies data management tasks, resulting in cleaner, more maintainable codebases and faster development cycles.

  3. Advanced Features: Tanstack Query provides a rich set of features, including optimistic updates, error handling, middleware support, and more, empowering developers to build resilient, feature-rich applications with ease.

  4. Scalability: Tanstack Query's architecture is designed for scalability, making it well-suited for applications of any size or complexity. Its declarative approach to data management ensures consistent and predictable behaviour, even as applications grow in size and complexity.

Happy Coding.