Skip to navigation

Hooks Reference

Every read hook returns a handle with the same shape and reads through the session on SchematicProvider:

interface ResourceHandle<T> {
data: T | undefined; // undefined until the first successful load; retained across later errors
error: Error | undefined; // the latest failure; cleared when the next request starts
isPending: boolean; // a request is in flight and nothing has arrived yet, or a refetch is running
refetch: () => void;
}

The three states a component renders follow from those fields. data === undefined && isPending is the first load. data === undefined && error is a failure with nothing to show. data !== undefined is ready, and a set error alongside it is a failure to report without replacing what is there.

Mounted outside any provider, every hook reports the missing source on its error rather than throwing, so a misconfigured page fails the same way a request would. Under a provider whose session is still undefined, the hooks stay pending instead, apart from useCatalog, which reads the public catalog meanwhile. Under session={null} they settle empty, again apart from useCatalog. The hooks never fetch during server rendering: without initialData they report pending on the server and load on the client.

The company endpoints, GET /company and the /company/* reads under it, are behind the company-context-api flag, per account, and answer 404 {"error":"Not found"} until it is on. A 404 there is the flag or the token, not a company that could not be found; ask Schematic to enable the flag for the account. Hooks surface it as an error rather than an empty answer, so a page cannot quietly report “nothing here” over real data. httpStatus(error) from @schematichq/schematic-components/elements reads the status off a billing error, and returns undefined for anything that was not an HTTP response.

The hooks are also re-exported from @schematichq/schematic-components/elements, so a file that imports a derivation can take its hook from the same place. Timestamps on every resource arrive as Date objects.

useCheckout

import type { Checkout, CheckoutPriceSnapshot, CheckoutProblem, CheckoutResult, CheckoutSelections } from "@schematichq/schematic-react";
declare function useCheckout(options?: { checkoutId?: string }): CheckoutHandle;
interface CheckoutHandle {
checkout: Checkout | undefined; // as the last write left it: id, version, status, selections, priceSnapshot, problems, expiresAt
snapshot: CheckoutPriceSnapshot | undefined;
problems: CheckoutProblem[]; // the priced checkout's, or the list a refused finalize answered with
isPricing: boolean; // a write is in flight or waiting behind one
isFinalizing: boolean;
error: Error | undefined; // a write or finalize that failed other than on its problems
result: CheckoutResult | undefined; // what the finalize charged
setSelections(selections: CheckoutSelections): Promise<Checkout | undefined>;
finalize(): Promise<CheckoutResult>;
reset(): void;
}

A persisted checkout on POST /checkouts, PUT /checkouts/{checkout_id} and POST /checkouts/{checkout_id}/finalize. The first setSelections opens it and every later one replaces the cart and re-prices on the server; rapid calls fold into the newest, a stale version is replayed once, and finalize charges under the session the last price handed out, then reloads every billing resource. A refused finalize puts the server’s list on problems and rejects, and checkoutProblemsOf(error) returns that list. { checkoutId } resumes a checkout, which js 1.16.1 and later read back before the first write. While isPricing is true, snapshot and problems are still the previous cart’s, so dim them until the write lands rather than show the old plan’s price under the new plan’s name. The drop-in dialog opens through useCheckoutLauncher. Checkout API describes the resource and the wire shapes.

useCheckoutLauncher

function useCheckoutLauncher(): { open(config?: CheckoutConfig): void; close(): void; isOpen: boolean };

Exported from @schematichq/schematic-components/elements alone, beside the dialog it opens. CheckoutLauncherProvider renders one <Checkout /> and useCheckoutLauncher() below it opens that dialog on config (selection, steps, display, catalogId), each section laid over the provider’s defaults; every open starts a new cart. Outside a provider open does nothing and says so in development. Checkout shows the pattern.

useTaxIds

function useTaxIds(): ResourceHandle<TaxId[]> & {
update(taxId: { type: TaxIdType; value: string }): Promise<void>;
isMutating: boolean;
mutationError: Error | undefined;
};

The tax IDs on the company’s billing customer, read from GET /checkout/tax-id: each has an id, its provider type such as eu_vat or us_ein, value, country and verificationStatus. data is empty when there are none. update posts one to POST /checkout/tax-id and reloads the list; it rejects with the failure and records it on mutationError. A finalize reads the tax ID from the billing customer rather than from the cart, so save it first, as the drop-in does when the field is left.

useCompany

function useCompany(): ResourceHandle<Company>;

The company and what it is subscribed to, read from GET /company:

FieldMeaning
id, nameThe company.
planThe current plan: id, name, description, price in minor units, period, and includedCreditIds. Absent when the company has no plan.
addOnsEach add-on the company holds, in the same shape as plan.
subscriptionstatus, currency, period, periodEnd, nextBillAt, trialEnd, cancelAt, and cancelAtPeriodEnd. Absent when the company has none, and when it has several.
scheduledDowngradefromPlanId, fromPlanName, toPlanId, and toPlanName for a downgrade that takes effect at period end.
customPlanBillingThe newest pending custom plan invoice: planId, planName, dueAt, invoiceUrl, and activationStrategy.

Plan Manager and the Unsubscribe Button read this, and so does the Pricing Table once a session starts.

useCatalog

function useCatalog(options?: { catalogId?: string }): ResourceHandle<CatalogView>;
type CatalogView =
| { source: "company"; catalog: Catalog }
| { source: "public"; catalog: PublicCatalog };

Plans, add-ons, and credit bundles with prices per period and currency, tier shapes, and entitlement summaries, for the environment’s catalog or the one catalogId names. The hook keys by value, so an inline object literal is fine.

The source follows the session. With a session it reads the company’s view from GET /catalog/view (or /catalogs/:catalog_id/view), which adds each plan’s and add-on’s standing: current, valid with an invalidReason and usageViolations when false, and companyCanTrial, plus capabilities.checkout for whether the company can check out at all. With no session, or while the session is still undefined, it reads GET /public/catalog (or /public/catalogs/:catalog_id) with the provider’s publishable key, so unlike the other hooks it does not wait on your auth. A session starting swaps the public catalog for the company’s view. data.source says which one you have; the public catalog carries no standing, so check it before reading current or valid.

Each plan’s autoTopups lists its included credits that top up: planCreditGrantId, which a checkout’s autoTopupOverrides name, creditId, the plan’s amount, thresholdCredits and enabled, the company’s own companyAmount, companyThresholdCredits and companyEnabled where availability is user_controlled, and unitPrice, the credit’s price a top-up is charged at. It carries no credit name: take it from the plan’s includedCreditGrants entry with the same creditId, or let deriveCheckout do both, since its autoTopups rows carry creditName and costText, the cost of one top-up at amount times the unit price.

A 404 from either means the account is not enabled for catalogs (the multi-product-catalog flag), or that catalogId names no catalog of the account’s. The Pricing Table reads this, and derivePricingTable takes it whole.

useFeatureUsage

function useFeatureUsage(): ResourceHandle<FeatureUsage[]>;

One row per feature the company is entitled to, read from GET /company/usage. An empty list is a loaded answer that means the company is entitled to nothing.

FieldMeaning
featureId, featureName, featureDescription, featureIconThe feature, with featureSingularName and featurePluralName where they are configured and "" where they are not, so fall back on featureName for an empty string as well as for a missing one.
featureTypeboolean, event, trait, or license.
accessWhether the company has access to the feature.
entitlementType, planEntitlementId, companyOverrideIdplan_entitlement or company_override, with the id of whichever it is. Only an override carries expiresAt.
valueType, valueNumeric, valueBoolThe entitlement’s value: boolean, numeric, unlimited, trait, or credit.
allocation, usage, softLimit, warningThresholdThe limit, the usage against it, an overage’s soft limit, and the warning threshold.
priceBehaviorpay_in_advance, pay_as_you_go, overage, tier, or credit_burndown. Absent when the feature is not priced.
price, currentCostThe metered price the company is billed at, tiers included, and what the feature costs at that price, in minor units.
metricPeriod, metricPeriodMonthReset, resetsAtThe usage period and when it next resets.
creditId, creditName, creditIcon, consumptionRateThe credit a credit-burndown feature draws on, with creditSingularName and creditPluralName where they are configured, and how much one use costs.
licenseId, perLicenseCreditGrantsThe license and the plan’s per-license credit grants, on a license feature.

Included Features, Metered Features, Plan Manager, and the Unsubscribe Button all read this, so a page showing all four makes one request.

useFeatureUserUsage

function useFeatureUserUsage(featureId: string): ResourceHandle<FeatureUserUsage>;

One event-based feature’s usage by user over its metric period, read from GET /company/usage/:feature_id/users. users holds up to FEATURE_USER_USAGE_LIMIT (20) of the heaviest users, each with userId, name, usage, share, and lastSeen. count is how many users there are in all, total is the period’s usage, startTime and endTime bound the period, and unattributed is usage from events sent without a user. A feature that is not an event-based entitlement of the company answers 404.

useCreditBalances

function useCreditBalances(): ResourceHandle<CreditBalanceEntry[]>;

One balance per credit, read from GET /company/credits. An empty list is a loaded answer that means the company holds no credits and its plan draws on none.

FieldMeaning
creditId, creditName, creditDescription, creditIconThe credit, with creditSingularName and creditPluralName where they are configured.
total, used, remainingAcross live grants, so total is used plus remaining.
resetsAt, expiresAtWhen the balance resets or runs out.
purchasableWhether a bundle of the credit is on offer to the company’s plan.
autoTopupenabled, selfService, amount, and thresholdCredits.
compositionHow a per-license plan grant adds up per period: licenseId, licenseName with licenseSingularName and licensePluralName, licenseQuantity, perLicenseAmount, fixedQuantity, total, period, and renewsAt.
grantsLive grants, newest first, each with id, grantReason, quantity, quantityUsed, quantityRemaining, createdAt, validFrom, renewalPeriod, resetsAt or expiresAt, and the plan or bundle behind it.

A credit the plan draws on that the company holds none of is listed with a zero balance and no grants. Expired and zeroed-out grants leave the list on the server.

useCreditUserUsage

function useCreditUserUsage(creditId: string): ResourceHandle<CreditUserUsage>;

One credit’s consumption by user over the span of its live grants, read from GET /company/credits/:credit_id/users. users holds up to FEATURE_USER_USAGE_LIMIT (20) of the heaviest users, each with userId, name, used, and share, alongside count, total, startTime, endTime, and unattributed. A credit the company holds no live grant of comes back empty; an unknown credit answers 404.

useUpcomingInvoice

function useUpcomingInvoice(): ResourceHandle<UpcomingInvoice | null>;

The next bill: amountDue and subtotal in minor units, currency, dueDate, the active discounts, and customerBalanceApplied and customerBalanceRemaining computed on the server. null is a loaded answer that means nothing to bill; only undefined means the resource has not loaded.

useInvoices

function useInvoices(query?: InvoiceQuery): ResourceHandle<InvoicePage> & { loadMore: () => Promise<void> };
type InvoiceQuery = { includePending?: boolean };
type InvoicePage = { invoices: Invoice[]; count: number; hasMore: boolean };

The invoice history, INVOICE_PAGE_SIZE (12) invoices a page. count is how many invoices the query matches on the server, so a list showing four rows can say how many the company has. hasMore is that count set against the rows loaded, and an empty page ends the list regardless. loadMore appends the next page, settles when it lands, and never rejects; read error for the failure, and the rows already fetched stay put. isPending is true while the page is on the wire, so a button can disable itself without awaiting anything, and clicking twice makes one request. refetch re-requests every page loaded so far, not only the first.

{ includePending: true } includes unpaid invoices that are not yet due. Each distinct query is its own list with its own paging, so useInvoices({ includePending: true }) and useInvoices() never share rows. The hook keys by value, so an inline object literal is fine.

An empty history is a successful response with count: 0. A 404 surfaces on error, so a page cannot report “no invoices” over a real failure.

Each Invoice:

FieldTypeMeaning
idstringThe invoice’s identifier.
amountDuenumberSigned, in the currency’s minor units. A negative amount is a credit note.
currencyCurrencyLowercase ISO 4217 code, such as usd.
createdAtDateWhen the invoice was created.
dueDateDate | null, optionalWhen it is due, if the billing provider set one.
statusInvoiceStatus | null, optionalopen or paid. The server leaves draft, uncollectible, and void invoices out of the history.
urlstring | null, optionalThe hosted invoice page, if there is one.

The optional fields arrive as null or are absent, so read them with ?? rather than a strict null check.

import { useInvoices } from "@schematichq/schematic-react";
function InvoiceHistory() {
const { data, error, isPending, loadMore, refetch } = useInvoices();
if (data === undefined) {
return error ? <button onClick={refetch}>Retry</button> : <p>Loading…</p>;
}
if (data.invoices.length === 0) {
return <p>No invoices yet</p>;
}
return (
<>
<ul>
{data.invoices.map((invoice) => (
<li key={invoice.id}>
<a href={invoice.url ?? undefined}>
{(invoice.dueDate ?? invoice.createdAt).toLocaleDateString()}
</a>{" "}
{invoice.amountDue / 100} {invoice.currency.toUpperCase()}
</li>
))}
</ul>
{data.hasMore && (
<button disabled={isPending} onClick={loadMore}>
Load more
</button>
)}
{error && <p role="alert">{error.message}</p>}
</>
);
}

usePaymentMethods

function usePaymentMethods(): ResourceHandle<PaymentMethod[]> & {
setDefault: (externalId: string) => Promise<void>;
remove: (id: string) => Promise<void>;
isMutating: boolean; // a write is in flight; distinct from isPending, which is the list loading
mutationError: Error | undefined; // the last write's failure; cleared when the next write starts
};

Every payment method on file. A company with none is a successful response with an empty list, so an empty data is a loaded answer. A 404 surfaces on error.

setDefault and remove each reload the list once they land, and reject with the failure while also recording it on mutationError, so a component can await the promise or read the field, whichever it prefers. A write’s failure never lands on error, which stays the list’s own. setDefault takes the method’s externalId, the billing provider’s id; remove takes Schematic’s id.

Each PaymentMethod:

FieldTypeMeaning
idstringSchematic’s identifier. remove takes this.
externalIdstringThe billing provider’s identifier. setDefault takes this.
typestringThe provider’s payment method type, such as card, us_bank_account, or link.
isDefaultbooleanThe method the company is billed with: the subscription’s own when it has one, else the customer’s default. At most one is true, and none may be.
canRemovebooleanWhether remove would be accepted. false only for the last method while a paid subscription that is active, trialing, or past due needs it.
cardBrand, cardLast4, cardExpMonth, cardExpYearoptionalThe card behind a card method, which is also how Stripe saves an Apple Pay or Google Pay card.
bankName, accountLast4optionalA bank account’s bank and last four digits.
accountName, billingName, billingEmailoptionalThe names and email the provider supplied, such as the email behind a Link account.

The optional fields arrive as null or are absent, so read them with ?? rather than a strict null check.

The server decides both flags, so a component never re-derives the provider’s rules. Removing the default promotes nothing; the list then has no default until someone sets one. Call setDefault with a new method’s id once Stripe confirms it; the server only makes a customer’s first method the default.

import { usePaymentMethods } from "@schematichq/schematic-react";
function Methods() {
const { data, isMutating, mutationError, remove, setDefault } = usePaymentMethods();
if (data === undefined) return <p>Loading…</p>;
return (
<>
<ul>
{data.map((method) => (
<li key={method.id}>
{method.cardBrand ?? method.type} {method.cardLast4 ?? method.accountLast4}
{method.isDefault ? (
" (default)"
) : (
<button disabled={isMutating} onClick={() => setDefault(method.externalId).catch(() => {})}>
Set default
</button>
)}
{method.canRemove && (
<button disabled={isMutating} onClick={() => remove(method.id).catch(() => {})}>
Remove
</button>
)}
</li>
))}
</ul>
{mutationError && <p role="alert">{mutationError.message}</p>}
</>
);
}

useSetupIntent

function useSetupIntent(): { create: () => Promise<SetupIntent> };
type SetupIntent = {
setupIntentClientSecret?: string | null;
publishableKey?: string | null; // the account's own Stripe key, for a direct account
schematicPublishableKey: string; // Schematic's key, for a connected account
accountId?: string | null; // the connected account, when there is one
};

Mints a Stripe SetupIntent for adding a payment method. Each call makes a new intent, because an intent is single-use. The hook holds no state; create rejects with the failure.

Load Stripe with schematicPublishableKey and stripeAccount: accountId when accountId is set, else with publishableKey. Mount Stripe’s Payment Element on the client secret, confirm the setup, then make the new method the default with usePaymentMethods().setDefault, which also reloads the list. Checkout with Stripe Elements walks through the Stripe side.

import { usePaymentMethods, useSetupIntent, type SetupIntent } from "@schematichq/schematic-react";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { useEffect, useState, type FormEvent } from "react";
// A connected account is reached through Schematic's key with the account
// named; a direct account uses its own key.
function stripeFor(intent: SetupIntent): Promise<Stripe | null> {
return intent.accountId
? loadStripe(intent.schematicPublishableKey, { stripeAccount: intent.accountId })
: loadStripe(intent.publishableKey ?? intent.schematicPublishableKey);
}
function AddPaymentMethod({ onDone }: { onDone: () => void }) {
const { create } = useSetupIntent();
// Stripe is loaded once per intent: <Elements> must keep the same promise.
const [ready, setReady] = useState<{ clientSecret: string; stripe: Promise<Stripe | null> }>();
const [error, setError] = useState<Error>();
useEffect(() => {
create().then(
(intent) => {
if (intent.setupIntentClientSecret) {
setReady({ clientSecret: intent.setupIntentClientSecret, stripe: stripeFor(intent) });
} else {
setError(new Error("The payment form could not be loaded."));
}
},
(failure: Error) => setError(failure),
);
}, [create]);
if (error) return <p role="alert">{error.message}</p>;
if (!ready) return <p>Loading…</p>;
return (
<Elements stripe={ready.stripe} options={{ clientSecret: ready.clientSecret }}>
<SaveForm onDone={onDone} />
</Elements>
);
}
function SaveForm({ onDone }: { onDone: () => void }) {
const stripe = useStripe();
const elements = useElements();
const { setDefault } = usePaymentMethods();
async function save(event: FormEvent) {
event.preventDefault();
if (!stripe || !elements) return;
const { setupIntent, error } = await stripe.confirmSetup({
elements,
confirmParams: { return_url: window.location.href },
redirect: "if_required",
});
const id = setupIntent?.payment_method;
if (!error && typeof id === "string") {
await setDefault(id); // the new method becomes the default, and the list reloads
onDone();
}
}
return (
<form onSubmit={save}>
<PaymentElement />
<button type="submit">Save payment method</button>
</form>
);
}

useUnsubscribe

function useUnsubscribe(): {
unsubscribe: () => Promise<void>;
isMutating: boolean; // the cancellation is in flight
mutationError: Error | undefined; // the last attempt's failure; cleared when the next one starts
};

Sets the company’s subscription to cancel at the end of its billing period. Once it lands, whichever of the company, the upcoming invoice, and the credit balances are loaded reload, so useCompany shows the cancellation date on its next render. unsubscribe rejects with the failure and records it on mutationError. The server refuses when the company has no subscription, when the subscription is already set to cancel at period end, or when another Stripe app manages it.

import { useCompany, useUnsubscribe } from "@schematichq/schematic-react";
function CancelLink() {
const { data: company } = useCompany();
const { unsubscribe, isMutating, mutationError } = useUnsubscribe();
if (!company?.subscription || company.subscription.cancelAt) return null;
return (
<>
<button disabled={isMutating} onClick={() => unsubscribe().catch(() => {})}>
Cancel at period end
</button>
{mutationError && <p role="alert">{mutationError.message}</p>}
</>
);
}

useInvalidateBillingData

function useInvalidateBillingData(): () => void;

Returns a function that reloads every loaded billing resource while keeping its current data on screen. Call it after an action that changes what the company is billed for, such as a plan change your own backend makes.

import { useInvalidateBillingData } from "@schematichq/schematic-react";
function UpgradeButton({ changePlan }: { changePlan: () => Promise<void> }) {
const invalidate = useInvalidateBillingData();
return <button onClick={async () => { await changePlan(); invalidate(); }}>Upgrade</button>;
}

Locale and copy

The drop-ins format and word everything through the locale, strings, and translate on SchematicProvider. These read the same settings, so a component of your own follows the same overrides. Localization covers the settings themselves.

// @schematichq/schematic-react
function useSchematicI18n(): { locale?: string; translate?: Translate; strings?: StringOverrides; onMissingString?: (key: string) => void };
function useSchematicLocale(): string | undefined;
function useSchematicTranslate(): Translate | undefined;
function useSchematicStrings(): StringOverrides | undefined;
// @schematichq/schematic-components/elements
function useResolvedLocale(locale?: string): string;
function useTranslator(strings?: StringOverrides, locale?: string): (key: StringKey, vars?: StringVars) => string;

The useSchematic* hooks return the provider’s settings as given: useSchematicLocale is undefined when the provider sets no locale, and outside any provider useSchematicI18n returns an empty object. useResolvedLocale applies the drop-ins’ fallback: the argument, else the provider’s locale, else the viewer’s. The viewer’s language is left out of a server render and its hydration, so set locale on the provider for a server-rendered page.

useTranslator returns the function the drop-ins word their copy with. It looks a key up in the strings you pass, then the provider’s strings, then the provider’s translate, then the English default, so it always returns a string. The customer portal recipe uses it to word a payment method’s label.

SchematicI18nProvider sets locale, translate, strings, and onMissingString for the part of the tree below it. SchematicProvider renders one, so you need it only to change the settings for one part of a page. A nested provider merges with the one above it: one that sets only locale keeps the translate above it, and strings combine key by key.

Testing and fixtures

BillingDataProvider serves fixed data to the hooks with no network and no session, so a component can be rendered in a test or a story with the rows you choose.

import { BillingDataProvider } from "@schematichq/schematic-react";
<BillingDataProvider data={{ invoices: { invoices: [], count: 0, hasMore: false } }}>
<InvoiceHistory />
</BillingDataProvider>;

data takes a value per resource: catalog, publicCatalog, company, featureUsage, creditBalances, upcomingInvoice, paymentMethods, and invoices. A missing key reports as pending. Given publicCatalog without catalog, useCatalog reports the public source, as on a signed-out page. The per-user breakdowns are not part of data: they have their own props, featureUserUsage and creditUserUsage, each keyed by feature or credit id. status overrides a resource’s isPending or error, so the loading and failure states render on demand, and onRefetch and onLoadMoreInvoices receive the calls a component makes. actions stands in for the writes: setDefaultPaymentMethod, removePaymentMethod, createSetupIntent, and unsubscribe receive the calls usePaymentMethods, useSetupIntent, and useUnsubscribe make, and whatever they resolve or reject with is what the component sees. The provider also accepts locale, strings, translate, and onMissingString, so a fixture can exercise copy overrides too.

Flags and entitlement checks

The hooks above read billing state. Feature flags and entitlement checks keep their own hooks on the same provider: useSchematicFlag, useSchematicEntitlement, useSchematicIsPending, useSchematicPlan, useSchematicCreditBalance, useSchematicCreditSpendPolicies, and useSchematicEvents. See the React SDK.