DEV Community

Cover image for Simplifying Data Fetching with Zustand and Tanstack Query: One Line to Rule Them All
koen de vulder
koen de vulder

Posted on • Edited on

Simplifying Data Fetching with Zustand and Tanstack Query: One Line to Rule Them All

In modern web development, managing data fetching, loading states, and error handling can quickly become complex and verbose. However, with the right tools and a bit of abstraction, we can significantly simplify this process. In this blog post, I'll show you how I used Zustand for state management and Tanstack Query (formerly React Query) to reduce all of this complexity to a single line of code in my React components.

The Problem

Typically, when fetching data in a React component, you need to manage several pieces of state:

  1. The fetched data
  2. Loading state
  3. Error state

You also need to handle the actual data fetching logic, error handling, and potentially implement a way to refetch the data. This can lead to a lot of boilerplate code in your components.

The Solution

By leveraging Zustand for state management, Tanstack Query for data fetching, and creating a centralized toast notification system, we can encapsulate all of this logic and expose a simple, clean API to our components. Here's how we did it:

Step 1: Set Up Zustand Store

First, we create a Zustand store to manage our global loading state:

import { create } from 'zustand';

interface LoaderState {
    isLoading: boolean;
    setIsLoading: (isLoading: boolean) => void;
}

export const useLoaderStore = create<LoaderState>()((set) => ({
    isLoading: false,
    setIsLoading: (isLoading: boolean) => set({ isLoading }),
}));
Enter fullscreen mode Exit fullscreen mode

We use Zustand because it provides a simple and lightweight solution for managing global state. In this case, we're using it to manage a global loading state that can be accessed and modified from anywhere in our application.

Step 2: Set Up ReactQueryProvider with Global Toast

We set up a ReactQueryProvider that includes a global toast system:

import React, { useRef } from 'react';
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toast } from 'primereact/toast';
import { TOAST_SEVERITY } from '@/app/ts/constants/ui';

let globalToast: React.RefObject<Toast> | null = null;

export const showToast = (severity: TOAST_SEVERITY, summary: string, detail: string, life: number = 5000) => {
    globalToast?.current?.show({ severity, summary, detail, life });
};

export function ReactQueryProvider({ children }: React.PropsWithChildren) {
    const toastRef = useRef<Toast>(null);
    globalToast = toastRef;

    const queryClient = new QueryClient({
        queryCache: new QueryCache({
            onError: (error: any, query) => {
                console.error(JSON.stringify(error));
            },
        }),
        mutationCache: new MutationCache({
            onError: (error: any, query) => {
                console.error(JSON.stringify(error));
            },
        }),
    });

    return (
        <QueryClientProvider client={queryClient}>
            <Toast ref={toastRef} />
            {children}
        </QueryClientProvider>
    );
}
Enter fullscreen mode Exit fullscreen mode

This setup provides a global showToast function that can be used anywhere in the application to display toast notifications.

Step 3: Create Error Notification Function

We create a centralized error notification function:

import { TOAST_SEVERITY } from '@/app/ts/constants/ui';
import { showToast } from '@/providers/ReactQueryProvider';

export interface CustomError extends Error {
    status?: number;
}

export const errorNotification = (isError: boolean, title: string, error: CustomError | null = null) => {
    if (isError && error) {
        showToast(TOAST_SEVERITY.ERROR, `${error.status}: ${title}`, error.message, 5000);
    }
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Create a Custom Hook for Error Notifications

We create a custom hook to handle error notifications:

import { useEffect } from 'react';
import { errorNotification } from '@/app/functions/errorResponse';
import { CustomError } from '@/app/ts/interfaces/global/customError';

export const useErrorNotification = (isError: boolean, title: string, error: CustomError | null = null) => {
    useEffect(() => {
        errorNotification(isError, title, error);
    }, [isError]);
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Create a Custom Data Fetching Hook

We create a custom hook for data fetching, which combines our loading state management and error notification:

import { useLoaderStore } from '@/stores/store';
import { CustomError } from '@/app/ts/interfaces/global/customError';
import { useErrorNotification } from '@/hooks/useErrorNotification';
import { useLoading } from '@/hooks/useLoading';

interface UseDataFetchingParams {
    isLoading: boolean;
    isError: boolean;
    error: CustomError | null;
    errorMessage: string;
}

export const useDataFetching = ({ isLoading, isError, error, errorMessage }: UseDataFetchingParams) => {
    const { setIsLoading } = useLoaderStore();
    useErrorNotification(isError, errorMessage, error);
    useLoading(isLoading, setIsLoading);
};
Enter fullscreen mode Exit fullscreen mode

This hook encapsulates the logic for updating the global loading state and handling error notifications.

Step 6: Create the CarApi

Next, we create an API service for handling car-related requests:

import { Car } from '@/app/ts/interfaces/car';

export const CarApi = {
    getActiveCars: async (): Promise<Car[]> => {
        const response = await fetch('/api/cars?active=true');
        if (!response.ok) {
            throw new Error('Failed to fetch active cars');
        }
        return response.json();
    },

    getCarsWithSpecificBrand: async (brandId: string, active: boolean = true): Promise<Car[]> => {
        const response = await fetch(`/api/cars?brandId=${brandId}&active=${active}`);
        if (!response.ok) {
            throw new Error('Failed to fetch cars for the specific brand');
        }
        return response.json();
    }
};
Enter fullscreen mode Exit fullscreen mode

This API service provides methods for fetching active cars and cars of a specific brand.

Step 7: Create a Custom Hook for Fetching Cars

Now, we can create a custom hook that uses Tanstack Query to fetch car data:

import { useQuery } from '@tanstack/react-query';
import { CarApi } from '@/app/api/carApi';
import { CARS } from '@/app/ts/constants/process';
import { useDataFetching } from '@/hooks/useDataFetching';
import { useQueryProps } from '@/app/ts/interfaces/configs/types';
import { ERROR_FETCHING_CARS } from '@/app/ts/constants/messages';

export const useCars = ({ filterObject = undefined, active = false, enabled = true }: useQueryProps) => {
    const errorMessage = ERROR_FETCHING_CARS;
    const getFilteredCars = async () => {
        if (Object.keys(filterObject || {}).length === 0 || filterObject === undefined) return await CarApi.getActiveCars();
        return await CarApi.getCarsWithSpecificBrand(filterObject.id, active);
    };
    const {
        data: cars,
        isLoading: isLoadingCars,
        refetch: refetchCars,
        error: errorCars,
        isError: isErrorCars,
    } = useQuery({
        queryKey: [CARS],
        queryFn: getFilteredCars,
        retry: 0,
        enabled,
    });

    useDataFetching({ isLoading: isLoadingCars, isError: isErrorCars, error: errorCars, errorMessage });

    return { cars, isLoadingCars, refetchCars, errorCars };
};
Enter fullscreen mode Exit fullscreen mode

Step 8: Use the Custom Hook in Your Component

Now, in your component, you can use the custom hook with a single line of code:

const { cars, refetchCars } = useCars({ filterObject: selectedBrand, active });
Enter fullscreen mode Exit fullscreen mode

This one line gives you access to:

  1. The fetched data (cars)
  2. A function to refetch the data (refetchCars)
  3. Automatic loading state management (using Zustand)
  4. Automatic error handling and notification (using the global toast system)

The Benefits

By using this approach with Zustand and Tanstack Query, we've gained several benefits:

  1. Simplified Component Code: Our components are now much cleaner and focused on rendering, not data management.
  2. Global State Management: Zustand provides an easy way to manage global state, like our loading indicator.
  3. Powerful Data Fetching: Tanstack Query handles caching, refetching, and background updates with minimal configuration.
  4. Centralized Error Handling: Our global toast system provides a consistent way to handle and display errors.
  5. Reusability: The useCars hook can be used in any component that needs to fetch car data.
  6. Consistency: Error handling and loading states are managed consistently across all components using this hook.
  7. Easy Refetching: If we need to refetch the data (e.g., after an update), we can simply call refetchCars().

Conclusion

By leveraging Zustand for state management, Tanstack Query for data fetching, and creating a centralized toast notification system, we've significantly simplified our data fetching process. This approach allows us to handle complex data management tasks with a single line of code in our components, leading to cleaner, more maintainable React applications.

Remember, the key to this simplification is moving the complexity into well-designed, reusable hooks and utilizing powerful libraries like Zustand and Tanstack Query. This way, we solve the problem once and benefit from the solution across our entire application.

Top comments (1)

Collapse
 
mikr13 profile image
Mihir Kumar

This is awesome, thanks for sharing!