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 (1)
This is awesome, thanks for sharing!