Build CoreOrdered learning track

Render Props and Function-as-Child

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 025

Render props and function-as-child as inversion-of-control composition patterns in modern React: scoped rendering, state exposure, API design, TypeScript contracts, modern alternatives, failure modes, and migration paths.

7 min read1319 words
PrevNext
Lesson 25123 lesson track24–67 Build Core
#react#component-composition#render-props#hooks+2 more

Part 025 — Render Props and Function-as-Child

Render prop adalah pola ketika sebuah komponen menerima fungsi render dari caller, lalu memanggil fungsi itu untuk menentukan UI yang akan ditampilkan.

Bentuk paling umum:

<DataLoader queryKey={['users']}>
  {({ data, isLoading, error, refetch }) => (
    <UserTable
      users={data ?? []}
      loading={isLoading}
      error={error}
      onRefresh={refetch}
    />
  )}
</DataLoader>

Di sini DataLoader memiliki behavior, state, lifecycle, atau orchestration. Caller memiliki markup.

Component owns behavior.
Caller owns rendering.
Render function connects both.

Ini bukan pola lama yang otomatis obsolete karena Hooks. Yang berubah adalah tempat terbaik untuk memakainya.

Custom Hook bagus untuk reusable stateful logic. Render prop bagus untuk reusable rendering boundary dan inversion of control.


1. Problem yang Diselesaikan

Tanpa render prop, komponen reusable sering jatuh ke salah satu dari dua ekstrem.

Ekstrem pertama: komponen terlalu opinionated.

<PermissionGate permission="invoice.approve" />

Masalahnya: komponen ini harus tahu UI mana yang ditampilkan ketika allowed, denied, loading, atau error.

Lalu API membengkak.

<PermissionGate
  permission="invoice.approve"
  allowedText="Approve"
  deniedText="No access"
  allowedIcon={<CheckIcon />}
  deniedIcon={<LockIcon />}
  buttonVariant="primary"
  deniedVariant="ghost"
  onAllowedClick={approve}
/>

Ekstrem kedua: semua logic dibocorkan ke caller.

const { allowed, loading, reason } = usePermission('invoice.approve')

if (loading) return <Spinner />
if (!allowed) return <Tooltip content={reason}><DisabledButton /></Tooltip>
return <Button onClick={approve}>Approve</Button>

Ini fleksibel, tapi bila pola sama muncul puluhan kali, logic UI-state dan policy rendering mulai tersebar.

Render prop memberi titik tengah:

<PermissionGate permission="invoice.approve">
  {({ allowed, loading, reason }) => {
    if (loading) return <Spinner />
    if (!allowed) {
      return (
        <Tooltip content={reason}>
          <Button disabled>Approve</Button>
        </Tooltip>
      )
    }
    return <Button onClick={approve}>Approve</Button>
  }}
</PermissionGate>

PermissionGate menjaga protokol permission. Caller tetap mengontrol UI.


2. Mental Model

Render prop adalah inversion of control.

Komponen biasa:

Caller passes data → Child renders fixed structure.

Render prop:

Caller passes rendering function → Child passes state back into caller-owned structure.

Ini mirip scoped slot.

A reusable component opens a scoped rendering zone.
Inside that zone, caller can access selected internal state and commands.

3. Render Prop vs Function-as-Child

Istilah sering bercampur.

Render prop

Render function dikirim lewat prop bernama eksplisit.

<Toggle
  render={({ on, toggle }) => (
    <button onClick={toggle}>{on ? 'On' : 'Off'}</button>
  )}
/>

Function-as-child

Render function dikirim lewat children.

<Toggle>
  {({ on, toggle }) => (
    <button onClick={toggle}>{on ? 'On' : 'Off'}</button>
  )}
</Toggle>

Keduanya konsep yang sama: callback render.

Perbedaan praktis:

render prop named explicitly  → clearer when multiple slots exist
function-as-child            → ergonomic for one main rendering zone

Gunakan children bila hanya ada satu zona render utama. Gunakan prop bernama bila ada banyak zona.

<DateRangePicker
  renderInput={({ start, end, open }) => ...}
  renderCalendar={({ month, days, selectDay }) => ...}
  renderFooter={({ apply, reset }) => ...}
/>

Namun hati-hati: banyak render prop dalam satu komponen sering sinyal bahwa yang dibutuhkan adalah compound/headless component, bukan configuration object.


4. Render Prop di Era Hooks

Sebelum Hooks, render prop sering dipakai untuk reusable stateful logic.

Contoh klasik:

<MousePosition>
  {({ x, y }) => <Cursor x={x} y={y} />}
</MousePosition>

Di era Hooks, logic ini lebih natural sebagai hook.

const { x, y } = useMousePosition()
return <Cursor x={x} y={y} />

Tapi kesimpulan “render prop selalu obsolete” salah.

Yang benar:

Jika targetnya reusable logic → custom hook lebih sering tepat.
Jika targetnya reusable rendering boundary → render prop masih tepat.

Contoh rendering boundary:

<AsyncBoundary promise={loadInvoice(id)}>
  {({ data }) => <InvoiceScreen invoice={data} />}
</AsyncBoundary>
<FeatureFlag name="new-invoice-layout">
  {({ enabled }) => enabled ? <NewInvoice /> : <OldInvoice />}
</FeatureFlag>
<FormField name="email">
  {({ inputProps, errorProps, error }) => (
    <Field>
      <Label>Email</Label>
      <Input {...inputProps} />
      {error && <ErrorMessage {...errorProps}>{error}</ErrorMessage>}
    </Field>
  )}
</FormField>

Di sini hook saja tidak selalu memberi boundary semantics yang jelas.


5. Kapan Render Prop Layak Dipakai

Gunakan render prop ketika komponen perlu mengontrol protokol tetapi caller perlu mengontrol tampilan.

Reusable behavior     yes
Markup flexibility    high
Internal state exposed selectively
Call site should remain explicit
Provider would be too broad
Compound API would be too heavy

Contoh cocok:

permission gate
feature flag gate
async/data boundary sederhana
responsive media query boundary
field binding boundary
virtualized row rendering
drag-and-drop item rendering
stateful overlay trigger rendering
accessibility primitive sederhana

Contoh kurang cocok:

shared app-wide state
complex multi-part widget
deep cross-tree communication
logic reused by many components without rendering boundary
render callback nested lebih dari 2 level
state machine workflow besar

6. Anatomy of a Good Render Prop API

Contoh sederhana: Toggle.

type ToggleRenderApi = {
  on: boolean
  setOn: (next: boolean) => void
  toggle: () => void
}

type ToggleProps = {
  defaultOn?: boolean
  children: (api: ToggleRenderApi) => React.ReactNode
}

export function Toggle({ defaultOn = false, children }: ToggleProps) {
  const [on, setOn] = React.useState(defaultOn)

  const toggle = React.useCallback(() => {
    setOn((current) => !current)
  }, [])

  const api = React.useMemo(
    () => ({ on, setOn, toggle }),
    [on, toggle],
  )

  return <>{children(api)}</>
}

Call site:

<Toggle defaultOn>
  {({ on, toggle }) => (
    <button aria-pressed={on} onClick={toggle}>
      {on ? 'Enabled' : 'Disabled'}
    </button>
  )}
</Toggle>

Perhatikan kontraknya:

on       = read model
setOn    = low-level command
toggle   = domain-like intent command
children = render function

Dalam API production, jangan selalu expose setter mentah. Lebih baik expose command yang menjaga invariant.

type DisclosureApi = {
  open: boolean
  openPanel: () => void
  closePanel: () => void
  togglePanel: () => void
}

Setter mentah mudah membuat caller melewati aturan internal.


7. Render Function Harus Pure

Render prop dipanggil saat render.

Artinya callback ini harus mematuhi prinsip render React:

no side effect
no mutation of external object
no network request
no subscription
no setState during render
no analytics event during render

Buruk:

<PermissionGate permission="invoice.approve">
  {({ allowed }) => {
    if (!allowed) {
      analytics.track('permission_denied_rendered')
    }

    return allowed ? <ApproveButton /> : <Denied />
  }}
</PermissionGate>

Analytics di atas terjadi saat render, bukan saat user melakukan action. Dalam concurrent rendering, render bisa diulang, dibatalkan, atau tidak pernah commit.

Lebih baik event-specific:

<PermissionGate permission="invoice.approve">
  {({ allowed, reason }) => (
    <Button
      disabled={!allowed}
      onClick={() => {
        if (!allowed) {
          analytics.track('permission_denied_click', { reason })
          return
        }
        approve()
      }}
    >
      Approve
    </Button>
  )}
</PermissionGate>

Atau commit-specific melalui effect di komponen sungguhan.


8. Jangan Memanggil Hooks di Dalam Render Prop Callback

Ini bug penting.

Buruk:

<Toggle>
  {({ on }) => {
    const id = React.useId() // jangan lakukan ini
    return <div id={id}>{on ? 'On' : 'Off'}</div>
  }}
</Toggle>

Render prop callback adalah fungsi biasa yang dipanggil oleh Toggle. Ia bukan komponen React yang dipanggil sebagai JSX oleh React.

Jika butuh Hooks, ekstrak menjadi komponen.

function ToggleView({ on, toggle }: ToggleRenderApi) {
  const id = React.useId()

  return (
    <button id={id} aria-pressed={on} onClick={toggle}>
      {on ? 'On' : 'Off'}
    </button>
  )
}

<Toggle>
  {(api) => <ToggleView {...api} />}
</Toggle>

Aturan praktis:

Hooks hanya dipanggil di component body atau custom hook.
Render prop callback bukan component body.

9. TypeScript Contract

Render prop API harus typed sebagai public contract.

type RenderProp<TApi> = (api: TApi) => React.ReactNode

type LoadState<TData, TError = Error> =
  | { status: 'idle'; data: null; error: null }
  | { status: 'loading'; data: null; error: null }
  | { status: 'success'; data: TData; error: null }
  | { status: 'error'; data: null; error: TError }

type ResourceBoundaryProps<TData, TError = Error> = {
  state: LoadState<TData, TError>
  children: RenderProp<{
    state: LoadState<TData, TError>
    hasData: boolean
    isLoading: boolean
    isError: boolean
  }>
}

Tapi jangan terlalu banyak boolean bila discriminated union sudah cukup.

Buruk:

type Api<T> = {
  data?: T
  error?: Error
  loading: boolean
  success: boolean
  failed: boolean
  empty: boolean
}

Lebih baik:

type Api<T> =
  | { status: 'loading' }
  | { status: 'empty' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

Caller dipaksa menangani state secara eksplisit.

<ResourceBoundary state={state}>
  {(api) => {
    switch (api.status) {
      case 'loading':
        return <Spinner />
      case 'empty':
        return <EmptyState />
      case 'success':
        return <Content data={api.data} />
      case 'error':
        return <ErrorState error={api.error} />
    }
  }}
</ResourceBoundary>

10. Case Study — Permission Boundary

Problem:

Beberapa action UI harus membaca permission.
Allowed/denied/loading/error harus konsisten.
Tampilan per use case harus fleksibel.
Permission logic tidak boleh diduplikasi di semua button.

API:

type PermissionGateApi = {
  status: 'checking' | 'allowed' | 'denied' | 'error'
  allowed: boolean
  reason?: string
  retry: () => void
}

type PermissionGateProps = {
  permission: string
  resourceId?: string
  children: (api: PermissionGateApi) => React.ReactNode
}

Implementasi sederhana:

function PermissionGate({ permission, resourceId, children }: PermissionGateProps) {
  const result = usePermissionCheck({ permission, resourceId })

  const api = React.useMemo<PermissionGateApi>(() => {
    if (result.status === 'loading') {
      return {
        status: 'checking',
        allowed: false,
        retry: result.retry,
      }
    }

    if (result.status === 'error') {
      return {
        status: 'error',
        allowed: false,
        reason: 'Unable to verify permission',
        retry: result.retry,
      }
    }

    if (!result.data.allowed) {
      return {
        status: 'denied',
        allowed: false,
        reason: result.data.reason,
        retry: result.retry,
      }
    }

    return {
      status: 'allowed',
      allowed: true,
      retry: result.retry,
    }
  }, [result.status, result.data, result.retry])

  return <>{children(api)}</>
}

Call site:

<PermissionGate permission="invoice.approve" resourceId={invoice.id}>
  {({ status, allowed, reason, retry }) => {
    if (status === 'checking') return <Button disabled>Checking...</Button>

    if (status === 'error') {
      return <Button onClick={retry}>Retry permission check</Button>
    }

    return (
      <Tooltip content={!allowed ? reason : undefined}>
        <Button disabled={!allowed} onClick={approveInvoice}>
          Approve
        </Button>
      </Tooltip>
    )
  }}
</PermissionGate>

Invariant:

Action UI never decides permission by itself.
PermissionGate exposes a small read model.
Caller owns presentation.

11. Case Study — Form Field Binding

Form field composition sering butuh state dan accessibility wiring.

<FormField name="email">
  {({ inputProps, labelProps, errorProps, error }) => (
    <div>
      <label {...labelProps}>Email</label>
      <input {...inputProps} type="email" />
      {error && <p {...errorProps}>{error}</p>}
    </div>
  )}
</FormField>

FormField dapat mengontrol:

field id
aria-describedby
aria-invalid
value binding
onChange bridge
onBlur bridge
touched state
error lookup

Caller tetap mengontrol markup.

Implementasi ringkas:

type FieldApi = {
  inputProps: React.InputHTMLAttributes<HTMLInputElement>
  labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
  errorProps: React.HTMLAttributes<HTMLElement>
  error?: string
}

function FormField({ name, children }: {
  name: string
  children: (api: FieldApi) => React.ReactNode
}) {
  const id = React.useId()
  const errorId = `${id}-error`
  const field = useFormField(name)

  const api = React.useMemo<FieldApi>(() => ({
    inputProps: {
      id,
      name,
      value: field.value,
      onChange: (event) => field.setValue(event.currentTarget.value),
      onBlur: field.markTouched,
      'aria-invalid': field.error ? true : undefined,
      'aria-describedby': field.error ? errorId : undefined,
    },
    labelProps: {
      htmlFor: id,
    },
    errorProps: {
      id: errorId,
      role: 'alert',
    },
    error: field.error,
  }), [id, name, field.value, field.error, field.setValue, field.markTouched, errorId])

  return <>{children(api)}</>
}

Poin penting:

Render prop bukan hanya memberi state.
Ia bisa memberi prop getter/prop bundle yang menjaga accessibility contract.

Ini jembatan menuju headless components.


12. Prop Getter Pattern

Render prop sering dipasangkan dengan prop getter.

Alih-alih expose semua event handler mentah:

{({ open, setOpen }) => ...}

Expose fungsi yang menggabungkan internal props dengan external props.

type MenuApi = {
  open: boolean
  getButtonProps: (
    props?: React.ButtonHTMLAttributes<HTMLButtonElement>,
  ) => React.ButtonHTMLAttributes<HTMLButtonElement>
  getMenuProps: (
    props?: React.HTMLAttributes<HTMLDivElement>,
  ) => React.HTMLAttributes<HTMLDivElement>
}

Call site:

<Menu>
  {({ open, getButtonProps, getMenuProps }) => (
    <>
      <button {...getButtonProps({ className: 'icon-button' })}>
        Options
      </button>

      {open && (
        <div {...getMenuProps({ className: 'surface' })}>
          ...
        </div>
      )}
    </>
  )}
</Menu>

Getter menghindari caller lupa atribut penting.

role
aria-expanded
aria-controls
id
tabIndex
onKeyDown composition
onClick composition
ref composition

Namun getter juga bisa menjadi opaque. Dokumentasikan dengan jelas.


13. Event Handler Composition

Prop getter harus menggabungkan handler internal dan external secara aman.

function composeEventHandlers<E extends React.SyntheticEvent>(
  userHandler: ((event: E) => void) | undefined,
  internalHandler: (event: E) => void,
) {
  return (event: E) => {
    userHandler?.(event)

    if (!event.defaultPrevented) {
      internalHandler(event)
    }
  }
}

Contoh:

function getButtonProps(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> = {},
) {
  return {
    ...props,
    type: props.type ?? 'button',
    'aria-expanded': open,
    onClick: composeEventHandlers(props.onClick, () => setOpen((v) => !v)),
  }
}

Ini memberi caller escape hatch:

<button
  {...getButtonProps({
    onClick: (event) => {
      if (disabledByBusinessRule) {
        event.preventDefault()
      }
    },
  })}
/>

Prinsip:

Caller boleh mencegah behavior internal secara eksplisit.
Internal behavior tetap default yang aman.

14. Render Prop dan Performance

Render function biasanya dibuat ulang setiap render.

<Toggle>{(api) => <Button {...api} />}</Toggle>

Ini normal. Jangan otomatis membungkus semua render prop dengan useCallback.

Yang penting adalah memahami konsekuensi.

Render prop dipanggil ketika parent render atau boundary render. Bila subtree mahal, optimasi di tempat yang tepat:

const ExpensivePanel = React.memo(function ExpensivePanel(props: Props) {
  return ...
})

<Toggle>
  {({ on, toggle }) => (
    <ExpensivePanel on={on} onToggle={toggle} />
  )}
</Toggle>

Atau gunakan component extraction.

function TogglePanel(api: ToggleRenderApi) {
  return <ExpensivePanel on={api.on} onToggle={api.toggle} />
}

<Toggle>{(api) => <TogglePanel {...api} />}</Toggle>

Jangan salah fokus:

Function identity is rarely the primary cost.
Expensive rendered subtree and unstable props usually are.

15. Render Prop vs Context

Render prop:

explicit at call site
narrow scope
good for one local boundary
no hidden dependency
can become nested/pyramidal

Context:

good for deep cross-tree access
implicit dependency
provider placement matters
can cause broad rerender if poorly designed

Contoh render prop lebih baik:

<MediaQuery query="(min-width: 1024px)">
  {({ matches }) => matches ? <DesktopNav /> : <MobileNav />}
</MediaQuery>

Context berlebihan untuk satu decision boundary.

Contoh context lebih baik:

<AuthProvider>
  <AppShell />
</AuthProvider>

Banyak komponen lintas tree butuh auth capability. Render prop akan menyebabkan prop drilling atau nesting.


16. Render Prop vs Custom Hook

Custom Hook:

function InvoiceActions({ invoice }: Props) {
  const permission = usePermission('invoice.approve', invoice.id)

  return ...
}

Render prop:

<PermissionGate permission="invoice.approve" resourceId={invoice.id}>
  {(permission) => ...}
</PermissionGate>

Gunakan custom hook jika:

consumer adalah komponen yang jelas
logic perlu dipakai bersama tanpa rendering boundary
consumer butuh bebas menggabungkan logic dengan hooks lain

Gunakan render prop jika:

ingin memaksa rendering protocol
ingin memberi scoped state pada satu zona JSX
ingin membungkus fallback/guard/boundary
ingin expose prop getters/accessibility wiring

Hybrid sering terbaik.

function PermissionGate(props: PermissionGateProps) {
  const api = usePermissionGate(props.permission, props.resourceId)
  return <>{props.children(api)}</>
}

Hook adalah implementation. Render prop adalah API surface.


17. Render Prop vs Compound Component

Render prop cocok untuk satu scoped rendering function.

<Toggle>{(api) => <button ... />}</Toggle>

Compound component cocok untuk multi-part widget.

<Tabs>
  <Tabs.List>
    <Tabs.Trigger value="a">A</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="a">...</Tabs.Content>
</Tabs>

Smell render prop harus berubah ke compound:

children callback menerima terlalu banyak prop
callback mengembalikan struktur multi-part kompleks
caller harus menyambungkan banyak id/aria/handler manual
ada List/Item/Trigger/Content/Overlay implicit relationship

Contoh kurang baik:

<Select>
  {({ selected, options, getTriggerProps, getListProps, getOptionProps }) => (
    <>
      <button {...getTriggerProps()}>{selected.label}</button>
      <ul {...getListProps()}>
        {options.map((option) => (
          <li {...getOptionProps(option)}>{option.label}</li>
        ))}
      </ul>
    </>
  )}
</Select>

Ini mungkin lebih baik sebagai headless/compound API.

<Select>
  <Select.Trigger />
  <Select.Content>
    {options.map((option) => (
      <Select.Option key={option.id} value={option.id}>
        {option.label}
      </Select.Option>
    ))}
  </Select.Content>
</Select>

18. Nested Render Props

Render prop mudah menjadi pyramid.

<AuthGate>
  {(auth) => (
    <FeatureFlag name="billing-v2">
      {(flag) => (
        <PermissionGate permission="invoice.pay">
          {(permission) => (
            <PaymentPanel auth={auth} flag={flag} permission={permission} />
          )}
        </PermissionGate>
      )}
    </FeatureFlag>
  )}
</AuthGate>

Ini smell orchestration.

Refactor 1: compose hooks inside a real component.

function PaymentPanelBoundary() {
  const auth = useAuth()
  const flag = useFeatureFlag('billing-v2')
  const permission = usePermission('invoice.pay')

  return <PaymentPanel auth={auth} flag={flag} permission={permission} />
}

Refactor 2: create orchestration boundary.

<PaymentCapabilityBoundary>
  {(capability) => <PaymentPanel capability={capability} />}
</PaymentCapabilityBoundary>

Refactor 3: page-level orchestration.

function InvoicePaymentPage() {
  const model = useInvoicePaymentPageModel()
  return <InvoicePaymentView model={model} />
}

Prinsip:

One render prop is composition.
Three nested render props are often missing orchestration.

19. Error Boundary Interaction

Render prop callback dieksekusi saat render boundary component. Error yang dilempar dari callback akan ditangkap oleh error boundary di atasnya, seperti error render biasa.

<ErrorBoundary fallback={<ErrorState />}>
  <DataBoundary>
    {({ data }) => <RiskyView data={data} />}
  </DataBoundary>
</ErrorBoundary>

Namun render prop component sendiri tidak otomatis menjadi error boundary.

Jangan campur konsep:

Render prop controls rendering delegation.
Error boundary controls render failure containment.
Suspense boundary controls async waiting UI.

Untuk production, boundary biasanya dipisah:

<ErrorBoundary fallback={<InvoiceError />}>
  <React.Suspense fallback={<InvoiceSkeleton />}>
    <InvoiceDataBoundary id={id}>
      {({ invoice }) => <InvoiceView invoice={invoice} />}
    </InvoiceDataBoundary>
  </React.Suspense>
</ErrorBoundary>

20. Controlled Render Prop Component

Render prop component tetap bisa controlled/uncontrolled.

type DisclosureProps = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  children: (api: DisclosureApi) => React.ReactNode
}

function Disclosure({ open, defaultOpen = false, onOpenChange, children }: DisclosureProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const isControlled = open !== undefined
  const actualOpen = isControlled ? open : uncontrolledOpen

  const setOpen = React.useCallback((next: boolean) => {
    if (!isControlled) setUncontrolledOpen(next)
    onOpenChange?.(next)
  }, [isControlled, onOpenChange])

  const api = React.useMemo(() => ({
    open: actualOpen,
    openPanel: () => setOpen(true),
    closePanel: () => setOpen(false),
    togglePanel: () => setOpen(!actualOpen),
  }), [actualOpen, setOpen])

  return <>{children(api)}</>
}

Invariant:

When controlled, parent owns state.
When uncontrolled, boundary owns state.
In both modes, caller owns rendering.

21. Library API Design Guidelines

A render prop API should be small, stable, and semantic.

Good:

type ComboboxItemApi<T> = {
  item: T
  active: boolean
  selected: boolean
  disabled: boolean
}

Poor:

type ComboboxItemApi<T> = {
  item: T
  internalIndex: number
  internalFocusIndex: number
  mutableRegistry: Map<string, unknown>
  setInternalFocusIndex: (index: number) => void
}

Expose product-level or widget-level concepts, not internals.

Checklist:

Does the API describe meaning, not implementation?
Can we change internal state shape without breaking users?
Does each command preserve invariant?
Are illegal states unrepresentable or at least hard to create?
Is the render callback pure by contract?
Can consumers test behavior without depending on internals?

22. Advanced Pattern — Children as State Projection

Sometimes render prop is best thought of as projection from internal state to UI.

<StateProjection state={state}>
  {(projection) => <View {...projection} />}
</StateProjection>

Example: approval status UI.

type ApprovalProjection =
  | { kind: 'can-submit'; label: string; submit: () => void }
  | { kind: 'blocked'; reason: string }
  | { kind: 'pending'; message: string }
  | { kind: 'completed'; receiptId: string }

<ApprovalActionProjection caseId={caseId}>
  {(action) => {
    switch (action.kind) {
      case 'can-submit':
        return <Button onClick={action.submit}>{action.label}</Button>
      case 'blocked':
        return <Tooltip content={action.reason}><Button disabled>Submit</Button></Tooltip>
      case 'pending':
        return <Button disabled>{action.message}</Button>
      case 'completed':
        return <ReceiptLink id={action.receiptId} />
    }
  }}
</ApprovalActionProjection>

This is powerful because the render prop boundary transforms messy workflow state into a stable UI decision model.


23. Migration Patterns

Render prop to custom hook

Before:

<OnlineStatus>
  {({ online }) => <StatusBadge online={online} />}
</OnlineStatus>

After:

function StatusBadgeContainer() {
  const online = useOnlineStatus()
  return <StatusBadge online={online} />
}

Use when boundary adds no value beyond logic reuse.

Custom hook to render prop boundary

Before:

const permission = usePermission(permissionName)
return renderPermission(permission)

After:

<PermissionGate permission={permissionName}>
  {renderPermission}
</PermissionGate>

Use when you want standardized permission rendering contract.

Render prop to compound component

Before:

<Tabs>{({ tabs, active, select }) => ...}</Tabs>

After:

<Tabs>
  <Tabs.List />
  <Tabs.Content />
</Tabs>

Use when multi-part coordination grows.

Render prop to headless hook

Before:

<Menu>{({ getButtonProps, getMenuProps }) => ...}</Menu>

After:

const menu = useMenu()
return ...

Use when consumers need full control and are comfortable wiring props.


24. Testing Render Prop Components

Test behavior through rendered output, not internal callback calls.

it('toggles state', async () => {
  const user = userEvent.setup()

  render(
    <Toggle>
      {({ on, toggle }) => (
        <button onClick={toggle}>{on ? 'On' : 'Off'}</button>
      )}
    </Toggle>,
  )

  expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument()

  await user.click(screen.getByRole('button'))

  expect(screen.getByRole('button', { name: 'On' })).toBeInTheDocument()
})

Test contract:

initial state
command behavior
controlled mode
uncontrolled mode
accessibility props if exposed
escape hatch behavior
callback purity cannot be fully tested but can be linted/reviewed

Avoid testing:

children called N times
internal state variable names
internal helper function calls
implementation-specific memoization

25. Failure Modes

25.1 Hooks inside render callback

Symptom:

Invalid hook call
state order bug
lint rule violation

Fix:

Extract callback body into real component.

25.2 Side effect during render callback

Symptom:

duplicate analytics
unexpected network call
setState during render error
behavior changes under Strict Mode/concurrent render

Fix:

Move side effects into event handlers or effects in real components.

25.3 Too many render API fields

Symptom:

caller knows internals
breaking changes become frequent
hard to document

Fix:

Expose semantic commands and discriminated read model.

25.4 Nested callback pyramid

Symptom:

hard-to-read JSX
unclear orchestration owner
scattered dependency logic

Fix:

Extract orchestration component or compose custom hooks.

25.5 Render prop used as global state solution

Symptom:

same boundary repeated everywhere
deep nesting
hard to coordinate cross-tree updates

Fix:

Use context, external store, server-state cache, or state machine depending on state type.

25.6 Prop getter overwrites caller behavior

Symptom:

onClick passed by caller does not run
aria props disappear
keyboard behavior breaks

Fix:

Compose handlers and merge props intentionally.

26. Decision Matrix

NeedPrefer
Reuse stateful logic onlyCustom Hook
Reuse one local rendering boundaryRender prop / function-as-child
Multi-part coordinated widgetCompound component
Logic-only accessible primitiveHeadless Hook or Headless Component
Deep cross-tree dependencyContext
Shared mutable client stateExternal store
Server-owned async dataServer-state cache
Complex workflow with legal transitionsState machine

27. Production Checklist

Before adding render prop API, ask:

Is the callback a pure render function?
Is this a local scoped boundary rather than global state?
Is the exposed API semantic and stable?
Can callers avoid knowing internal state shape?
Are commands named as user/domain intents?
Can controlled and uncontrolled modes coexist safely if needed?
Is there a clear migration path if the component becomes compound/headless?
Are accessibility props impossible or hard to forget?
Can the component be tested from user-visible behavior?

28. Practical Exercise

Build three variants of the same behavior.

Feature:

A collapsible panel with open/close/toggle, controlled and uncontrolled mode, keyboard support, and analytics on user-triggered toggle.

Implement as:

1. custom hook: useDisclosure
2. render prop: <Disclosure>{api => ...}</Disclosure>
3. compound component: <Disclosure.Root><Disclosure.Trigger /><Disclosure.Panel /></Disclosure.Root>

Then compare:

Which API is easiest for one-off use?
Which API is safest for design system use?
Which API has best accessibility defaults?
Which API leaks the least internal state?
Which API creates the least nesting?

The goal is not to pick one pattern forever. The goal is to understand which boundary each pattern creates.


29. Key Takeaways

Render prop is not dead. It is narrower.

Use it when you need:

local scoped rendering
caller-owned markup
component-owned protocol
explicit state exposure
small boundary with controlled flexibility

Avoid it when:

it becomes global state
it becomes a nested pyramid
it exposes internals
it replaces a simple custom hook
it tries to model a complex multi-part widget alone

The strongest React engineers do not memorize patterns. They understand boundary shape.

Render prop is a boundary where behavior flows down into a callback and rendering flows back out as JSX.


References

Lesson Recap

You just completed lesson 25 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.