> ## Documentation Index
> Fetch the complete documentation index at: https://outline.letraz.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Fetching

> Data Fetching Architecture with TanStack Query and Server Actions in Letraz's client side repository. This architecture provides a robust solution for data fetching in Next.js applications featuring seamless server-side and client-side data fetching, efficient caching and revalidation, type-safe API interactions, comprehensive error handling, feature-based organization, and built-in support for infinite scrolling.

<img className="rounded-lg" src="https://mintcdn.com/letraz/-Vzv-DpMsTOASyZ0/images/data-fetching.png?fit=max&auto=format&n=-Vzv-DpMsTOASyZ0&q=85&s=da88006dc7173a614d347c7799679955" alt="Data fetching cover image" width="1920" height="1440" data-path="images/data-fetching.png" />

## Overview

This architecture provides a robust solution for data fetching in Next.js applications by combining Server Actions with TanStack Query. We use this in our Letraz's client side code. Key features:

* 🔄 Seamless server-side and client-side data fetching
* 📦 Efficient caching and revalidation
* 🛡️ Type-safe API interactions
* 🚨 Comprehensive error handling
* 🎯 Feature-based organization
* ♾️ Built-in support for infinite scrolling

<Note>
  Please note that this documentation is tailored for the Letraz's client side
  code and is an evolving document. The information provided here is subject to
  change based on the evolving requirements of the project.
</Note>

## Setup

```bash theme={null}
# Install required dependencies
bun install @tanstack/react-query @tanstack/react-query-devtools
```

### Provider Setup

<CodeGroup>
  ```tsx /components/providers/api-provider.tsx theme={null}
  "use client"

  import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
  import { ReactQueryDevtools } from "@tanstack/react-query-devtools"

  export const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        refetchOnWindowFocus: false,
      },
    },
  })

  const APIProvider = ({ children }: React.PropsWithChildren) => (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )

  export default APIProvider
  ```

  ```tsx /components/providers/index.tsx theme={null}
  import { APIProvider } from "@/lib/config/api-client"

  export const Providers = ({ children }: React.PropsWithChildren) => (
    <ReactQueryProvider>{children}</ReactQueryProvider>
  )
  ```

  ```tsx /app/layout.tsx theme={null}
  import { Providers } from "@/components/providers"

  const RootLayout = ({ hildren }: React.PropsWithChildren) => (
    <body>
      <Providers>{children}</Providers>
    </body>
  )

  export default RootLayout
  ```
</CodeGroup>

## Core Types

```typescript /lib/config/api-types.ts theme={null}
export interface ApiError {
  message: string
  code: string
  details?: Record<string, string[]>
}

export interface ApiResponse<T> {
  data?: T
  error?: ApiError
  meta?: {
    timestamp: string
    version: string
  }
}

export interface PaginatedResponse<T> {
  results: T[]
  count: number
  next: string | null
  previous: string | null
  page_size: number
  current_page: number
  total_pages: number
}
```

## Project Structure

```bash theme={null}
/lib
├── config
│   └── api-client.ts
│   └── api-types.ts
│
├── [feature-tag]  (replace [feature-tag] with the actual feature name)
│   ├── hooks
│   │   └── useFeatureHook.ts
│   ├── queries.ts
│   ├── mutations.ts
│   ├── actions.ts
│   ├── types.ts
│   └── keys.ts
│
└── misc  (optional for general or shared code)
    └── someOtherFile.ts

```

## Implementation Details

### API Client Configuration

```typescript /lib/config/api-client.ts theme={null}
import * as Sentry from "@sentry/nextjs"
import { ApiError } from "@/lib/config/api-types"

type RequestOptions = {
  method?: string
  headers?: Record<string, string>
  body?: any
  cookie?: string
  params?: Record<string, string | number | boolean | undefined | null>
  cache?: RequestCache
  next?: NextFetchRequestConfig
}

const buildUrlWithParams = (
  url: string,
  params?: RequestOptions["params"]
): string => {
  if (!params) return url
  const filteredParams = Object.fromEntries(
    Object.entries(params).filter(
      ([, value]) => value !== undefined && value !== null
    )
  )
  if (Object.keys(filteredParams).length === 0) return url
  const queryString = new URLSearchParams(
    filteredParams as Record<string, string>
  ).toString()
  return `${url}?${queryString}`
}

// Create a separate function for getting server-side cookies that can be imported where needed
export const getServerCookies = async () => {
  if (typeof window !== "undefined") return ""

  // Dynamic import next/headers only on server-side
  try {
    const { cookies } = await import("next/headers")
    const cookieStore = await cookies()

    // Define the cookies to be included in the request to the API
    const selectedCookies = ["__session"]

    // Construct the Cookie header manually
    return selectedCookies
      .map((name) => {
        const cookie = cookieStore.get(name)
        return cookie ? `${cookie.name}=${cookie.value}` : null
      })
      .filter(Boolean) // Remove null values
      .join(" ")
  } catch (error) {
    Sentry.captureException(error)
    return ""
  }
}

const fetchApi = async <T>(
  url: string,
  options: RequestOptions = {}
): Promise<T> => {
  const {
    method = "GET",
    headers = {},
    body,
    cookie,
    params,
    cache = "no-store",
    next,
  } = options

  // Get cookies from the request when running on server
  let cookieHeader = cookie
  if (typeof window === "undefined" && !cookie) {
    cookieHeader = await getServerCookies()
  }

  const fullUrl = buildUrlWithParams(`${process.env.API_URL}${url}`, params)

  const response = await fetch(fullUrl, {
    method,
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      ...headers,
      ...(cookieHeader ? { Cookie: cookieHeader } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
    credentials: "include",
    cache,
    next,
  })

  if (!response.ok) {
    const error = ((await response.json()) || response.statusText) as ApiError
    if (typeof window !== "undefined") {
      // error handing
    }

    throw new Error(error.message)
  }

  return response.json()
}

export const api = {
  get: async <T>(url: string, options?: RequestOptions): Promise<T> => {
    return fetchApi<T>(url, { ...options, method: "GET" })
  },
  post: async <T>(
    url: string,
    body?: any,
    options?: RequestOptions
  ): Promise<T> => {
    return fetchApi<T>(url, { ...options, method: "POST", body })
  },
  put: async <T>(
    url: string,
    body?: any,
    options?: RequestOptions
  ): Promise<T> => {
    return fetchApi<T>(url, { ...options, method: "PUT", body })
  },
  patch: async <T>(
    url: string,
    body?: any,
    options?: RequestOptions
  ): Promise<T> => {
    return fetchApi<T>(url, { ...options, method: "PATCH", body })
  },
  delete: async <T>(url: string, options?: RequestOptions): Promise<T> => {
    return fetchApi<T>(url, { ...options, method: "DELETE" })
  },
}
```

### Custom React Query Hooks Example

#### Query Hooks

<CodeGroup>
  ```tsx /hooks/useQuery.ts theme={null}
  import {useQuery} from '@tanstack/react-query'
  import {api} from '@/lib/api-client'

  // Basic query hook
  export const useApiQuery = <T,>(options?: QueryOptions<T>) => {
  return useQuery({
  queryKey: [key],
  queryFn: () => api.get<ApiResponse<T>>(endpoint).then((res) => res.data),
  ...options,
  })
  }

  ```

  ```tsx /hooks/useServerQuery.ts theme={null}
  import {useQuery} from '@tanstack/react-query'
  import {api} from '@/lib/api-client'

  export const useServerQuery = <T>(
      queryKey: string[],
      serverAction: () => Promise<T>, options?: QueryOptions<T>
  ) => {
      return useQuery({
          queryKey,
          queryFn: serverAction,
          ...options
      })
  }
  ```

  ```tsx /hooks/useInfiniteApiQuery.ts theme={null}
  import { useInfiniteQuery } from "@tanstack/react-query"
  import { api } from "@/lib/api-client"

  export const useInfiniteApiQuery = () => {
    return useInfiniteQuery({
      queryKey: [key],
      queryFn: async ({ pageParam = 1 }) => {
        const response = await api.get<PaginatedResponse>("/")
        return response.data
      },
      initialPageParam: 1,
      getNextPageParam: (lastPage) => {
        if (!lastPage.next) return undefined
        const url = new URL(lastPage.next)
        return url.searchParams.get("page")
      },
    })
  }
  ```
</CodeGroup>

#### Mutation Hooks

```typescript theme={null}
// hooks/useCustomMutation.ts
import { useMutation, MutationOptions } from "@tanstack/react-query"
import { api } from "../lib/api-client"

export const useCustomMutation = <TData, TVariables>(
  options?: MutationOptions<TData, Error, TVariables>
) => {
  return useMutation({
    mutationFn: (variables: TVariables) =>
      api({
        url: "/",
        method: "POST", // method
        data: variables,
      }),
    ...options,
  })
}
```

### Server Actions Integration

```typescript theme={null}
// feature-tag/actions.ts
"use server"

import { api } from "@/lib/api/client"
import { ApiResponse, User } from "@/types"
import { revalidateTag } from "next/cache"

export async function getUser(id: string): Promise<ApiResponse<User>> {
  try {
    const response = await api.get<ApiResponse<User>>(`/api/users/${id}`)
    revalidateTag(`user-${id}`)
    return response.data
  } catch (error) {
    throw error
  }
}

// Usage
// hooks/queries/useUser.ts
export function useUser(id: string) {
  return useServerQuery({
    queryKey: ["user", id],
    queryFn: () => getUser(id),
  })
}
```

### Error Handling

```tsx theme={null}
// in queries
const { isError, error, isLoadingError } = useQuery({
  queryKey: [""],
  queryFn: async () => 0,
})

useEffect(() => {
  // handing error
}, [error])

// show different components
if (isError) {
  return <p>Error</p>
}
if (isLoadingError) {
  return <p>Error</p>
}

// in mutations
const customMutation = useMutation({
  mutationFn: () => {},
  onError: () => {
    // error handing
  },
})
```

### Query Key Management

```typescript theme={null}
// lib/utils/query-keys.ts
export const queryKeys = {
  users: {
    all: ["users"] as const,
    detail: (id: string) => ["users", id] as const,
    posts: (id: string) => ["users", id, "posts"] as const,
  },
  posts: {
    all: ["posts"] as const,
    detail: (id: string) => ["posts", id] as const,
  },
} as const
```

## Using Prefetched Data with Hydration

### Key Concepts

1. **Prefetching Data**: Fetch data on the server using TanStack Query's `queryClient.prefetchQuery`.
2. **Hydration**: Pass server-fetched data to the client using the `dehydrate` and `Hydrate` APIs.

### Prefetch Data in a Page

In Next.js page (`page.tsx`), fetch data on the server and use `dehydrate` to serialize it for hydration:

```tsx theme={null}
// app/page.tsx
import {
  HydrationBoundary,
  dehydrate,
  useQueryClient,
} from "@tanstack/react-query"
import { pokemonQueryOptions } from "@/lib/actions"
import PokemonList from "@/components/PokemonList"

export const revalidate = 0 // Disable ISR for simplicity

export default async function HomePage() {
  // Prefetch data on the server
  const queryClient = useQueryClient()

  await queryClient.prefetchQuery(pokemonQueryOptions)

  // Dehydrate the data
  const dehydratedState = dehydrate(queryClient)

  return (
    <HydrationBoundary state={dehydratedState}>
      <PokemonList />
    </HydrationBoundary>
  )
}
```

### Fetch Data with TanStack Query

Create a utility to fetch data:

```tsx theme={null}
// lib/fetchers.ts
"use server"

export async function fetchPokemonList() {
  const res = await fetch("https://pokeapi.co/api/v2/pokemon?limit=20")
  if (!res.ok) throw new Error("Failed to fetch Pokémon data")
  return res.json()
}
```

In custom hook, use `useQuery` to access the hydrated data:

```tsx theme={null}
// feature-tag/queries.ts
"use client"

import { useQuery, queryOptions } from "@tanstack/react-query"
import { fetchPokemonList } from "@/lib/features/pokemon/actions"

export const pokemonQueryOptions = queryOptions({
  queryKey: ["pokemon"],
  queryFn: fetchPokemonList,
})

export default const usePokemonList = () => {
  return useQuery(pokemonQueryOptions)
}

```

```tsx theme={null}
// app/components/PokemonList.tsx
"use client"

import { usePokemonList } from "@/lib/features/pokemon/queries"

export default function PokemonList() {
  const { data, isLoading, isError } = usePokemonList()

  if (isLoading) return <p>Loading...</p>
  if (isError) return <p>Error fetching Pokémon data</p>

  return (
    <ul className="space-y-2">
      {data.results.map((pokemon: { name: string }, index: number) => (
        <li key={index} className="capitalize">
          {pokemon.name}
        </li>
      ))}
    </ul>
  )
}
```

## Optimistic Updates

### 1: Make a Server Action

```typescript theme={null}
// actions.ts
"use server" // This is a server action

import {api} from "@/lib/config/api-client"
import {Experience, ExperienceMutation, ExperienceMutationSchema, ExperienceSchema} from "@/lib/experience/types"

export const addExperienceToDB = async (experienceValues: ExperienceMutation): Promise<Experience> => {
  const params = ExperienceMutationSchema.parse(experienceValues)
  const data = await api.post<Experience>("/resume/base/experience/", params)
  return ExperienceSchema.parse(data)
};

```

### 2: Create a Custom Hook for Optimistic Updates

```typescript theme={null}
// mutations.ts
import {Experience, ExperienceMutation} from '@/lib/experience/types'
import {MutationOptions, useMutation} from '@tanstack/react-query'
import {deleteExperienceFromDB, addExperienceToDB} from '@/lib/experience/actions'

export const useAddUserExperienceMutation = (options?: MutationOptions<Experience, Error, ExperienceMutation>) => useMutation(
	{
		mutationFn: addExperienceToDB,
		...options // Open Close Principle - Open for extension, closed for modification
	}
)

export const useDeleteExperienceMutation = (options?:MutationOptions<void, Error, string>) => useMutation({
	mutationFn: (experienceId) => deleteExperienceFromDB(experienceId, 'base'),
	...options
})



```

### 3: Make Reuseable Query Options

```tsx theme={null}
// queries.ts
import {queryOptions, useQuery} from '@tanstack/react-query'
import {EXPERIENCE_KEYS} from './keys' // 
import {getExperiencesFromDB} from './actions'

export const experienceQueryOptions = queryOptions({
	queryKey: EXPERIENCE_KEYS,
	queryFn: () => getExperiencesFromDB('base')
})

// not needed for optimistic updates
export const useCurrentExperiences = () => useQuery(experienceQueryOptions)

```

### 3: Use the Hook in a Component

```tsx theme={null}
'use client' // This is a client side component
const queryClient = useQueryClient()


// hook in action
const {mutateAsync, isPending} = useAddUserExperienceMutation({
  onMutate: async (newExperience) => {
    // Cancel any outgoing re-fetches
    // (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries(experienceQueryOptions)
    
    
    // Snapshot the previous value
    const prevExperiences = queryClient.getQueryData(experienceQueryOptions.queryKey)
    // Optimistically update to the new value
    queryClient.setQueryData(experienceQueryOptions.queryKey, (oldData:any) => oldData ? [...oldData, newExperience] : oldData)
    // TODO remove this any the

    // Return a context object with the snapshotted value
    return {prevExperiences}
  },
  // If the mutation fails,
  // use the context returned from onMutate to roll back
  // TODO remove this any the
  onError: (err, newExperience, context:any) => {
    queryClient.setQueryData(experienceQueryOptions.queryKey, context?.prevExperiences)
    throw Error('Failed to add experience')
  },
  // Always refetch after error or success:
  onSettled: () => {
    queryClient.invalidateQueries(experienceQueryOptions)
  }
})


// handle mutation
const handleSubmit = async (values: ExperienceMutation) => {
  try {
    await mutateAsync(values)
  } catch (error) {
    toast.error('Failed to add experience')
  }
}
```

For more details, refer to the following links.

* [Example Repo](https://github.com/sahilverma-dev/tanstack-query-nextjs14-example)
* [Server Rendering & Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr)
* [Advanced Server Rendering](https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr)
* [Next 14 + React Query COMBO with Server Actions and RSC](https://www.youtube.com/watch?v=yVsaCVEfPn4)
* [TanStack Query docs](https://tanstack.com/query)
* [Next.js App Router docs](https://nextjs.org/docs/app)

## Reference

* [Bullet Proof React - Next JS](https://github.com/alan2207/bulletproof-react/blob/master/apps/nextjs-app/)
* [Design Patterns in React](https://www.youtube.com/playlist?list=PLApy4UwQM3Updrw-4mOXTwgsWar9bqk6i)
