Documentation
Framework
Version

React Example: React Router

tsx
import { Form, Link, useFetcher, useLoaderData } from 'react-router-dom'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { getContact, updateContact } from '../contacts'
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router-dom'
import type { Contact } from '../contacts'
import type { QueryClient } from '@tanstack/react-query'

export const contactDetailQuery = (id: string) =>
  queryOptions({
    queryKey: ['contacts', 'detail', id],
    queryFn: async () => {
      const contact = await getContact(id)
      if (!contact) {
        throw new Response('', {
          status: 404,
          statusText: 'Not Found',
        })
      }
      return contact
    },
  })

export const loader =
  (queryClient: QueryClient) =>
  async ({ params }: LoaderFunctionArgs) => {
    if (!params.contactId) {
      throw new Error('No contact ID provided')
    }
    await queryClient.ensureQueryData(contactDetailQuery(params.contactId))
    return { contactId: params.contactId }
  }

export const action =
  (queryClient: QueryClient) =>
  async ({ request, params }: ActionFunctionArgs) => {
    const formData = await request.formData()
    if (!params.contactId) {
      throw new Error('No contact ID provided')
    }
    await updateContact(params.contactId, {
      favorite: formData.get('favorite') === 'true',
    })
    await queryClient.invalidateQueries({ queryKey: ['contacts'] })
    return null
  }

export default function Contact() {
  const { contactId } = useLoaderData() as Awaited<
    ReturnType<ReturnType<typeof loader>>
  >
  const { data: contact } = useSuspenseQuery(contactDetailQuery(contactId))

  return (
    <div id="contact">
      <div>
        <img key={contact.avatar} src={contact.avatar || undefined} />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{' '}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter && (
          <p>
            <a target="_blank" href={`https://twitter.com/${contact.twitter}`}>
              {contact.twitter}
            </a>
          </p>
        )}

        {contact.notes && <p>{contact.notes}</p>}

        <div>
          <Link to="edit" className="button">
            Edit
          </Link>
          <Form
            method="post"
            action="destroy"
            onSubmit={(event) => {
              if (!confirm('Please confirm you want to delete this record.')) {
                event.preventDefault()
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  )
}

function Favorite({ contact }: { contact: Contact }) {
  const fetcher = useFetcher({ key: `contact:${contact.id}` })
  let favorite = contact.favorite
  if (fetcher.formData) {
    favorite = fetcher.formData.get('favorite') === 'true'
  }

  return (
    <fetcher.Form method="post">
      <button
        name="favorite"
        value={favorite ? 'false' : 'true'}
        title={favorite ? 'Remove from favorites' : 'Add to favorites'}
      >
        {favorite ? '★' : '☆'}
      </button>
    </fetcher.Form>
  )
}
import { Form, Link, useFetcher, useLoaderData } from 'react-router-dom'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { getContact, updateContact } from '../contacts'
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router-dom'
import type { Contact } from '../contacts'
import type { QueryClient } from '@tanstack/react-query'

export const contactDetailQuery = (id: string) =>
  queryOptions({
    queryKey: ['contacts', 'detail', id],
    queryFn: async () => {
      const contact = await getContact(id)
      if (!contact) {
        throw new Response('', {
          status: 404,
          statusText: 'Not Found',
        })
      }
      return contact
    },
  })

export const loader =
  (queryClient: QueryClient) =>
  async ({ params }: LoaderFunctionArgs) => {
    if (!params.contactId) {
      throw new Error('No contact ID provided')
    }
    await queryClient.ensureQueryData(contactDetailQuery(params.contactId))
    return { contactId: params.contactId }
  }

export const action =
  (queryClient: QueryClient) =>
  async ({ request, params }: ActionFunctionArgs) => {
    const formData = await request.formData()
    if (!params.contactId) {
      throw new Error('No contact ID provided')
    }
    await updateContact(params.contactId, {
      favorite: formData.get('favorite') === 'true',
    })
    await queryClient.invalidateQueries({ queryKey: ['contacts'] })
    return null
  }

export default function Contact() {
  const { contactId } = useLoaderData() as Awaited<
    ReturnType<ReturnType<typeof loader>>
  >
  const { data: contact } = useSuspenseQuery(contactDetailQuery(contactId))

  return (
    <div id="contact">
      <div>
        <img key={contact.avatar} src={contact.avatar || undefined} />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{' '}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter && (
          <p>
            <a target="_blank" href={`https://twitter.com/${contact.twitter}`}>
              {contact.twitter}
            </a>
          </p>
        )}

        {contact.notes && <p>{contact.notes}</p>}

        <div>
          <Link to="edit" className="button">
            Edit
          </Link>
          <Form
            method="post"
            action="destroy"
            onSubmit={(event) => {
              if (!confirm('Please confirm you want to delete this record.')) {
                event.preventDefault()
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  )
}

function Favorite({ contact }: { contact: Contact }) {
  const fetcher = useFetcher({ key: `contact:${contact.id}` })
  let favorite = contact.favorite
  if (fetcher.formData) {
    favorite = fetcher.formData.get('favorite') === 'true'
  }

  return (
    <fetcher.Form method="post">
      <button
        name="favorite"
        value={favorite ? 'false' : 'true'}
        title={favorite ? 'Remove from favorites' : 'Add to favorites'}
      >
        {favorite ? '★' : '☆'}
      </button>
    </fetcher.Form>
  )
}