Hooks

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

HookWrapsReturns
useBookingPage(slug)Get a booking page{ data, status, error, isLoading }
useAvailableSlots(slug, date)List open slots{ slots, status, error, isLoading, refetch }
useCreateBooking()Create a booking{ create, data, status, error, isLoading }
useClndr()The provider's clientClndr

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

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.

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

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.

/** `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

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).

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

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 page documents each. A page picker, for example:

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:

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 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:

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.statusLikely cause
400A missing field, a bad email address, or a timestamp that doesn't parse
409Slot taken, or not an open slot (message above)
401Missing or revoked key, or requests going to https://clndr.pro instead of www
403The page isn't on this key's account
429The 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():

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 lists every message the API sends.