Core concepts

State Management

We use Tanstack Query for state management in the application. Tanstack Query is a data synchronization and caching library that simplifies fetching, caching, and updating data from APIs.

Global state management

Ideally, all application state should be managed using Tanstack Query. This promotes a centralized and consistent approach to state management.

When additional state is needed, we use React Contexts. For example, for user authentication, we have an Auth Context (see /contexts/auth) that utilizes local storage to store user authentication credentials and provides a useAuth() hook for accessing the authenticated user and related functions.

Occasionally it may be necessary to build similar context providers which use local storage to cache data, but this adds complexity, so always first consider storing the data on the backend and using Tanstack Query to retrieve and cache it locally.


Custom Query Hooks

To simplify API calls and add consistent error handling and authentication, we have created custom query hooks that wrap the useQuery hook from Tanstack Query. These custom hooks are located in hooks/queries.tsx.

Wrapping useQuery

Each custom query hook follows a similar pattern:

  1. It wraps the useQuery hook and provides a specific query key and query function.
  2. It adds error handling logic, such as retry behavior and redirection to the authentication page in case of unauthorized access.
  3. It combines the authLoading state from the useAuth hook with the isLoading state from the query response to provide a unified loading state.

Here's an example of a custom query hook for retrieving a user:

export const useRetrieveUser = () => {
  const router = useRouter();
  const { authUser, authLoading } = useAuth();
  const pathname = usePathname();

  const queryResponse = useQuery<User, ApiError | Error>({
    queryKey: ['user', authUser?.user.id],
    queryFn: () => retrieveUser(authUser?.token),
    enabled: !authLoading,
    retry: (failureCount, error) => {
      if (error instanceof ApiError && error.status === 401) {
        if (!pathname.includes('/auth')) {
          router.push('/auth');
        }
        return false;
      }
      return failureCount < 3;
    }
  });

  // Combine authLoading with queryResponse.isLoading
  const isLoading = authLoading || queryResponse.isLoading;

  return { ...queryResponse, isLoading };
};

In this example, the useRetrieveUser hook wraps the useQuery hook and provides the necessary query key and query function to retrieve a user. It also adds error handling logic to redirect to the authentication page if an unauthorized access error occurs. Finally, it combines the authLoading state with the isLoading state from the query response to provide a unified loading state.

Using Custom Query Hooks

To use a custom query hook in a component, simply import it and call it within the component. The hook will return the query response object, which includes properties like data, isLoading, error, etc.

Here's an example of using the useRetrieveUser hook in a component:

import { useRetrieveUser } from 'hooks/queries';

function UserProfile() {
  const { data: user, isLoading, error } = useRetrieveUser();

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

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

By using custom query hooks, we can ensure consistent error handling, authentication, and loading state management across all API calls in our application. This promotes code reusability, maintainability, and a better developer experience.

Custom Query Hooks vs. Props

When it comes to fetching and managing data in React components, there are two common approaches: using custom query hooks or passing data through props. In this project, we prioritize the use of custom query hooks over passing data through props whenever possible.

Benefits of Custom Query Hooks

  1. Encapsulation: Custom query hooks encapsulate the data fetching logic and state management within the component itself. This makes the component more self-contained and reduces its dependency on parent components for data.

  2. Reusability: Custom query hooks can be easily reused across multiple components that require the same data. This promotes code reusability and maintainability, as the data fetching logic is centralized in a single place.

  3. Separation of Concerns: By using custom query hooks, we separate the data fetching logic from the component's rendering logic. This improves code readability and makes the component more focused on its primary responsibility of rendering the UI.

  4. Simplified Component Hierarchy: When data is passed through props, it often leads to prop drilling, where props are passed down through multiple levels of components. This can make the component hierarchy more complex and harder to maintain. Custom query hooks eliminate the need for prop drilling, as each component can fetch its own data independently.

When to Use Props

While custom query hooks are preferred for data fetching, there are still scenarios where passing data through props is appropriate:

  1. Passing Static Data: If the data is static and doesn't require fetching from an API, it can be passed as props to the component.

  2. Passing Data Between Closely Related Components: When data needs to be shared between a parent component and its direct children, passing data through props can be a simple and straightforward approach.

  3. Passing Callbacks: If a child component needs to communicate back to its parent component or trigger an action in the parent, passing callbacks as props is a common pattern.

In general, we recommend using custom query hooks for data fetching and state management, and reserving props for passing static data, sharing data between closely related components, or passing callbacks.

Example: useRetrieveUser Hook

Let's take a look at an example of how the useRetrieveUser hook is used in a component:

import { useRetrieveUser } from 'hooks/queries';

function UserProfile() {
  const { data: user, isLoading, error } = useRetrieveUser();

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

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

In this example, the UserProfile component uses the useRetrieveUser hook to fetch the user data. The hook encapsulates the data fetching logic and returns the user data, isLoading state, and error state. The component can then render the user's name and email based on the fetched data, without the need for the data to be passed down from a parent component.

By favoring custom query hooks over passing data through props, we aim to build a more modular, reusable, and maintainable codebase.

Custom Mutation Hooks

In addition to custom query hooks, we have also created custom mutation hooks to simplify API calls for mutations (POST, PUT, DELETE) and add consistent error handling, authentication, and toast notifications. These custom mutation hooks are located in hooks/mutations.tsx.

Wrapping useMutation

Similar to custom query hooks, custom mutation hooks follow a specific pattern:

  1. They wrap the useMutation hook from Tanstack Query and provide a specific mutation function.
  2. They add error handling logic and authentication using the useAuth hook.
  3. Depending on the use case, they can include toast notifications for success and error responses using the useToast hook.

Here's an example of a custom mutation hook for creating a transaction:

const useCreateTransaction = (
  options?: Omit<UseMutationOptions<TransactionResponse, Error, TransactionRequest>, 'mutationFn'>
) => {
  const { authUser } = useAuth();

  return useMutation<TransactionResponse, Error, TransactionRequest>({
    mutationFn: (transactionData: TransactionRequest) =>
      createTransaction(authUser?.token, transactionData),
    ...options,
  });
};

In this example, the useCreateTransaction hook wraps the useMutation hook and provides the necessary mutation function to create a transaction. It also uses the useAuth hook to access the authenticated user's token for authentication.

Adding Toast Notifications

Depending on the use case, custom mutation hooks can include toast notifications for success and error responses. Here's an example of a custom mutation hook that includes toast notifications:

const useCreateMetric = (
  options?: Omit<UseMutationOptions<CreateMetricResponse, Error, CreateMetricRequest>, 'mutationFn'>
) => {
  const { authUser } = useAuth();
  const { toast } = useToast();

  return useMutation<CreateMetricResponse, Error, CreateMetricRequest>({
    mutationFn: (metricData: CreateMetricRequest) =>
      createMetric(authUser?.token, metricData),
    ...options,
    onSuccess: (data, variables, context) => {
      toast({
        title: "Success",
        description: "Metric created successfully",
      });
      options?.onSuccess?.(data, variables, context);
    },
    onError: (error, variables, context) => {
      toast({
        title: "Error",
        description: `${
          error instanceof ApiError || error instanceof AuthError
            ? error.message
            : 'Something went wrong. Please try again later.'
        }`,
      });
      options?.onError?.(error, variables, context);
    },
  });
};

In this example, the useCreateMetric hook includes toast notifications for both success and error responses. It uses the useToast hook to display the appropriate toast messages based on the mutation outcome.

Previous
Template overview