Skip to content

React hooks

startup-builder/react binds the typed client to React, with live reads, Verbs that follow their Action to the end, Events and Experiment arms.

startup-builder/react binds $ to React components, with the same types and Permissions. Its hooks read records and stay live, run Verbs and follow each Action to its end, and hear Events.

Set up

The hooks ship in the startup-builder package. Mount one provider at the root of the client tree:

TypeScript (JSX)
'use client'

import { StartupProvider } from 'startup-builder/react'

export function Providers({ children }: { children: React.ReactNode }) {
  return <StartupProvider publishableKey={process.env.NEXT_PUBLIC_STARTUP_BUILDER_KEY}>{children}</StartupProvider>
}

A publishable key, pk_test_ or pk_live_, runs only the Verbs a Startup marks public and reads only published records. Once a person signs in through the SignIn Component, every hook acts as that person, inside their Tenant.

A Startup's own App mounts the provider for you. A secret key never reaches the browser; the key guide lists which key runs where.

The hooks

Each hook takes an SDK method itself, such as $.Invoice.list, so its input and result are typed from the Verb.

CallReturnsRuns
useRead(method, input){ data, error, loading }, kept liveget or any read
useList(method, input){ data, more, after, loading }, kept livelist, one page at a time
useVerb(method){ run, preview, action, pending, error }Any Verb, in Mode send
useAction(id)The Action, kept liveAction.get
useEvent(name, handler)NothingA stream of Events
useVariant(flag, options)The arm's keyFlag.evaluate
useStartup()The $ the provider holdsAnything $ runs

Read records

leaseline.app tracks lease renewals for small property managers at $29 a month. Its billing page lists a manager's Invoices:

TypeScript (JSX)
'use client'

import { $ } from 'startup-builder'
import { useList } from 'startup-builder/react'

export function Invoices() {
  const { data, more, loading } = useList($.Invoice.list, { limit: 20 })
  if (loading) return <p>Loading invoices</p>

  return (
    <ul>
      {data.map((invoice) => <li key={invoice.$id}>{invoice.number}: {invoice.status}</li>)}
      {more && <button onClick={more}>Show more</button>}
    </ul>
  )
}

Reads stay live. When Invoice.collected fires, the row re-renders as paid with no refetch and no polling code. Components that read the same record share one request and one subscription.

A manager without the Permission to list Invoices gets error, the forbidden problem, and no rows.

more loads the next page with the after cursor and appends it, so the list keeps its order as records arrive.

Run a Verb

useVerb returns run, which sends the Verb and follows its Action until it ends:

TypeScript (JSX)
'use client'

import { $ } from 'startup-builder'
import { useVerb } from 'startup-builder/react'

export function SwitchToAnnual({ subscription }: { subscription: string }) {
  const { run, pending, action, error } = useVerb($.Subscription.switch)

  return (
    <>
      <button disabled={pending} onClick={() => run({ subscription, offer: 'offer_annual' })}>Switch to annual</button>
      {action?.reason === 'awaitingAction' && <p>Waiting for approval</p>}
      {error && <p>{error.fix}</p>}
    </>
  )
}

pending stays true while the Action is pending, whatever it waits on: a queue, an answer, or an Approval past a Limit. The ActionStatus Component renders every reason the same way the platform's own screens do.

An Action that ends failed or refused sets error to its problem, with fix saying what to change. preview(input) runs the same Verb in Mode try: every check runs and nothing is stored. Each run carries its own idempotency key, so a double click sends one call.

Hear Events

useEvent subscribes while the component is mounted and unsubscribes when it unmounts:

TypeScript (JSX)
useEvent('Invoice.collected', (event) => notify(`Paid: ${event.data.invoice.number}`))

The stream reconnects from the last sequence number it saw, so a laptop that sleeps misses nothing. Realtime covers selectors and cursors.

Experiment arms

useVariant returns the arm of an Experiment the reader is assigned, the same arm on every visit:

TypeScript (JSX)
const arm = useVariant('annual-first', { default: 'control' })

return <h2>{arm === 'annual' ? 'Two months free, billed yearly' : 'Every lease renewal, tracked'}</h2>

The unit is the signed-in person, or the Visitor before sign-in. The Exposure is recorded on the render that shows the arm, never on a prefetch. The experiments file declares the arms and the Metric that judges them.

Server and client

Server code calls $ directly and passes what it read to the client. A hook given initial renders that value at once and stays live from there:

TypeScript (JSX)
// app/billing/page.tsx, a server component
import { $ } from 'startup-builder'
import { Plan } from './plan'

export default async function Billing({ params }: { params: { subscription: string } }) {
  const subscription = await $.Subscription.get({ id: params.subscription })
  return <Plan initial={subscription} />
}
TypeScript (JSX)
// app/billing/plan.tsx, a client component
'use client'

import { $, type Subscription } from 'startup-builder'
import { useRead } from 'startup-builder/react'

export function Plan({ initial }: { initial: Subscription }) {
  const { data } = useRead($.Subscription.get, { id: initial.$id }, { initial })
  return <p>Renews {data.renewsAt}</p>
}
Code runs inUsesActs as
A server component, a route handler or a script$The secret key's Worker
A Startup's own Functionctx.$The Function's Worker, on the Action that called it
A client componentThe hooksThe signed-in person, or the Visitor