# Hooks

useBookingPage, useAvailableSlots, useCreateBooking and useClndr from @clndr-pro/react — signatures, return values, local dates, refetching, and handling a slot someone else just took.

The hooks are the data layer under `BookingForm`, exposed so you can draw your own booking UI. They need a [`ClndrProvider`](/react/components#clndrprovider) above them and, like everything in `@clndr-pro/react`, run only in `'use client'` files.

| Hook                            | Wraps                                                          | Returns                                        |
| ------------------------------- | -------------------------------------------------------------- | ---------------------------------------------- |
| `useBookingPage(slug)`          | [Get a booking page](/api-reference/operations/getBookingPage) | `{ data, status, error, isLoading }`           |
| `useAvailableSlots(slug, date)` | [List open slots](/api-reference/operations/listSlots)         | `{ slots, status, error, isLoading, refetch }` |
| `useCreateBooking()`            | [Create a booking](/api-reference/operations/createBooking)    | `{ create, data, status, error, isLoading }`   |
| `useClndr()`                    | The provider's client                                          | `Clndr`                                        |

`status` is `'idle' | 'loading' | 'success' | 'error'` everywhere. The hooks don't cache between components or retry; each one is a `useEffect` around one SDK call.

## useBookingPage

```ts
function useBookingPage(slug: string | null): {
  data: BookingPageDetail | null; // { bookingPage, userProfile, questions }
  status: 'idle' | 'loading' | 'success' | 'error';
  error: Error | null;
  isLoading: boolean;
};
```

Fetches the page, its host's profile and its questions. Pass `null` to skip fetching (while you don't know the slug yet). It refetches when `slug` changes, and keeps the previous page in `data` until the new one arrives.

```tsx
const { data: page, error } = useBookingPage('intro-call');

if (error) return <p>{error.message}</p>; // "Booking page not found" for a bad slug
if (!page) return <Spinner />;

const { bookingPage, userProfile, questions } = page;
```

Given a slug, the hook starts in `status: 'loading'`, so checking `isLoading` works from the first render. Checking "no data and no error", as above, works too, and also covers `@clndr-pro/react` 0.2.0, which started in `idle` and could show a not-found state for a frame.

## useAvailableSlots

```ts
function useAvailableSlots(slug: string | null, date: Date | null): {
  slots: TimeSlot[]; // [{ start, end }], UTC ISO strings
  status: 'idle' | 'loading' | 'success' | 'error';
  error: Error | null;
  isLoading: boolean;
  refetch: () => void; // ask again for the same slug and day
};
```

Fetches the open slots for one day. `date` is sent as its local calendar date (`2026-10-15`), and the API reads that as the host's calendar day, in the host's timezone. The slots that come back are UTC instants; format them for the guest with `Intl.DateTimeFormat` or `toLocaleTimeString`. When host and guest are far apart, some of the host's day can fall on the guest's previous or next date, so group by the guest's date if you show dates next to times. Past slots and days beyond the page's `max_days_ahead` come back empty. Slots are sorted by start time.

The hook refetches when `slug` changes, when `date` points at a different instant, and when you call `refetch()`. It compares the date's timestamp, not the object, so building `new Date(...)` during render is fine. While a request is in flight `slots` still holds the previous list, so render from `isLoading` first. `error` is cleared each time a new request starts.

**Build dates from local parts.** A date picker's `Date` (shadcn's `Calendar`, react-day-picker) is already local midnight. From an `<input type="date">`, split the string: `new Date('2026-10-15')` is UTC midnight, which is still the 14th for anyone west of UTC.

```ts
/** `YYYY-MM-DD` from <input type="date"> → a Date at local midnight. */
function parseLocalDate(value: string) {
  const [y, m, d] = value.split('-').map(Number);
  return new Date(y, m - 1, d);
}
```

Upgrading from `@clndr-pro/react` 0.2.0? Its `useAvailableSlots` had no `refetch`, skipped a request that matched the previous slug and day, and could sit on `loading` forever under React Strict Mode (every `next dev`) when a component mounted with a date already set. It also sent a `Date` as a full UTC timestamp, which the API reads as "the host's day containing this instant", one day off for many timezone pairs. 0.2.1 (with SDK 0.1.5) fixes all of that.

## useCreateBooking

```ts
function useCreateBooking(): {
  create: (input: CreateBookingInput) => Promise<Booking>;
  data: Booking | null;
  status: 'idle' | 'loading' | 'success' | 'error';
  error: ClndrError | Error | null;
  isLoading: boolean;
};

interface CreateBookingInput {
  bookingPageId: string; // page.bookingPage.id
  guestName: string;
  guestEmail: string;
  startTime: string;     // a slot's `start`, unchanged
  endTime: string;       // the same slot's `end`
  responses?: Array<{ questionId: string; answer: string }>;
}
```

`create` both updates the hook's state and returns (or throws) the result, so you can `await` it in a submit handler. Catch the rejection: the error is on `error` too, and an uncaught rejection in an event handler only ends up in the console (`<BookingForm>` catches its own).

```tsx
const { create, data: booking, error, isLoading } = useCreateBooking();

async function onSubmit() {
  try {
    await create({
      bookingPageId: page.bookingPage.id,
      guestName: name,
      guestEmail: email,
      startTime: slot.start,
      endTime: slot.end,
      responses: [{ questionId: questions[0].id, answer: topic }],
    });
  } catch {
    // shown from `error`
  }
}

if (booking) {
  return <p>{booking.status === 'pending' ? 'Request sent' : "You're booked"}</p>;
}
```

The returned `Booking` is `confirmed` on *direct* pages and `pending` on *approval* pages; on direct pages with Meet links on it already carries `google_meet_link`. It doesn't include the answers.

Send each answer once, only for questions on this page. One unknown `questionId` and none of the answers are saved (the booking still is). Skip questions the guest left blank, and check required ones before you call `create`: the API doesn't.

## useClndr

```ts
function useClndr(): Clndr;
```

The provider's client, for calls the other hooks don't cover. With a publishable key that's `bookingPages.list()`, `bookingPages.get()`, `bookingPages.getSlots()` and `bookings.create()`; the [TypeScript SDK](/sdk) page documents each. A page picker, for example:

```tsx title="components/booking-page-list.tsx"
'use client';

import { useEffect, useState } from 'react';
import { useClndr, type BookingPage } from '@clndr-pro/react';

export function BookingPageList({ onPick }: { onPick: (slug: string) => void }) {
  const clndr = useClndr();
  const [pages, setPages] = useState<BookingPage[] | null>(null);

  useEffect(() => {
    clndr.bookingPages
      .list()
      .then((all) => setPages(all.filter((p) => p.is_active && p.visibility === 'public')))
      .catch(() => setPages([]));
  }, [clndr]);

  if (!pages) return <p>Loading…</p>;
  return (
    <ul>
      {pages.map((p) => (
        <li key={p.id}>
          <button type="button" onClick={() => onPick(p.slug)}>
            {p.title} · {p.duration_minutes} min
          </button>
        </li>
      ))}
    </ul>
  );
}
```

`list()` returns every page on the account, inactive and private ones included, so filter before showing it to guests.

## A minimal custom flow

All three hooks together, unstyled, in one component:

```tsx title="components/minimal-booking.tsx"
'use client';

import { useState, type FormEvent } from 'react';
import { useAvailableSlots, useBookingPage, useCreateBooking, type TimeSlot } from '@clndr-pro/react';

/** `YYYY-MM-DD` from <input type="date"> → a Date at local midnight. */
function parseLocalDate(value: string) {
  const [y, m, d] = value.split('-').map(Number);
  return new Date(y, m - 1, d);
}

export function MinimalBooking({ slug }: { slug: string }) {
  const { data: page, error: pageError } = useBookingPage(slug);
  const [day, setDay] = useState('');
  const date = day ? parseLocalDate(day) : null;
  const { slots, isLoading: loadingSlots } = useAvailableSlots(slug, date);
  const [slot, setSlot] = useState<TimeSlot | null>(null);
  const { create, data: booking, error, isLoading } = useCreateBooking();

  if (pageError) return <p>{pageError.message}</p>;
  if (!page) return <p>Loading…</p>;
  if (booking) return <p>{booking.status === 'pending' ? 'Request sent.' : 'Booked!'} Check your inbox.</p>;

  async function book(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!slot || !page) return;
    const form = new FormData(e.currentTarget);
    await create({
      bookingPageId: page.bookingPage.id,
      guestName: String(form.get('name')),
      guestEmail: String(form.get('email')),
      startTime: slot.start,
      endTime: slot.end,
    }).catch(() => {}); // the hook's `error` has the message
  }

  return (
    <div>
      <h2>{page.bookingPage.title}</h2>
      <input type="date" value={day} onChange={(e) => { setDay(e.target.value); setSlot(null); }} />
      {loadingSlots ? <p>Loading times…</p> : null}
      <div>
        {slots.map((s) => (
          <button key={s.start} type="button" aria-pressed={slot?.start === s.start} onClick={() => setSlot(s)}>
            {new Date(s.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
          </button>
        ))}
      </div>
      {slot ? (
        <form onSubmit={book}>
          <input name="name" placeholder="Name" required />
          <input name="email" type="email" placeholder="Email" required />
          <button disabled={isLoading}>{isLoading ? 'Booking…' : 'Book'}</button>
          {error ? <p role="alert">{error.message}</p> : null}
        </form>
      ) : null}
    </div>
  );
}
```

It skips the host's questions and doesn't refetch a taken slot; the [shadcn booking widget](/tutorials/shadcn-booking-widget) tutorial handles both and adds a calendar.

## A slot someone else just took

Two guests can pick the same time. The second `create` fails with a `ClndrError` with status `409`. With a publishable key (the usual case in the browser) the message is usually "This time slot is not available. Fetch the slots again and pick another.", because the API checks the slot is still open before booking; "This time slot is no longer available. Please pick another." means both requests passed that check at the same moment. Every other failure is a `ClndrError` too, with the API's message:

```ts
import { ClndrError } from '@clndr-pro/react';

function isSlotTaken(err: unknown) {
  return err instanceof ClndrError && (err.status === 409 || /no longer available|not available/i.test(err.message));
}
```

| `err.status` | Likely cause                                                                                                                                               |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`        | A missing field, a bad email address, or a timestamp that doesn't parse                                                                                    |
| `409`        | Slot taken, or not an open slot (message above)                                                                                                            |
| `401`        | Missing or revoked key, or requests going to `https://clndr.pro` instead of `www`                                                                          |
| `403`        | The page isn't on this key's account                                                                                                                       |
| `429`        | The key's rate limit, or the guest's email used its 5 booking attempts this clock hour; `err.data` has the message, the `Retry-After` header isn't exposed |

After a taken slot, clear the picked slot and ask for a fresh list with `refetch()`:

```tsx
const { slots, refetch } = useAvailableSlots(slug, date);

// in the submit handler's catch:
if (isSlotTaken(err)) {
  setSlot(null);
  refetch();
}
```

`ClndrError` has `message`, `status` (the HTTP status) and `data` (the parsed response body, `{ error: string }`). [Errors and rate limits](/errors) lists every message the API sends.
