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:
- The fetched data
- Loading state
- 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 }),
}));
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>
);
}
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);
}
};
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]);
};
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);
};
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();
}
};
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 };
};
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 });
This one line gives you access to:
- The fetched data (
cars
) - A function to refetch the data (
refetchCars
) - Automatic loading state management (using Zustand)
- 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:
- Simplified Component Code: Our components are now much cleaner and focused on rendering, not data management.
- Global State Management: Zustand provides an easy way to manage global state, like our loading indicator.
- Powerful Data Fetching: Tanstack Query handles caching, refetching, and background updates with minimal configuration.
- Centralized Error Handling: Our global toast system provides a consistent way to handle and display errors.
-
Reusability: The
useCars
hook can be used in any component that needs to fetch car data. - Consistency: Error handling and loading states are managed consistently across all components using this hook.
-
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 (0)