Capítulo 51 de 51

Chapter 51: useRender

Core Idea

The hook that implements the render prop pattern (ch007) for your own custom components — pass a defaultTagName, the consumer's render prop, component state, and merged props, and it returns the finished React element; this is literally what every Base UI part uses internally, and is Base UI's direct answer to Radix's Slot/asChild.

Key Concepts

  • Minimal usage: useRender({ defaultTagName: 'p', render, props }) — renders a <p> by default, or whatever render overrides to, with props spread onto it.
  • With state: pass state (an object) as a third input — it's forwarded as the second argument to a function-form render prop, and Base UI auto-converts state properties to data-* attributes (customizable via stateAttributesMapping, e.g. { isActive: (v) => v ? {'data-is-active': ''} : null }).
  • Two prop-typing interfaces: useRender.ComponentProps<'button'> types a component's external props (includes render); useRender.ElementProps<'button'> types just the internal default HTML props you construct before merging — use the first for your component's exported prop type, the second for the literal defaultProps object inside it.
  • Merging own defaults with consumer props: always combine via mergeProps(defaultProps, otherProps) (ch050) before passing to props: — never spread manually, or you lose event-handler/className merging.
  • Ref handling (React 19 vs. 18/17): in React 19, no forwardRef needed — pass your internal ref directly as ref: internalRef (or an array [forwardedRef, internalRef] if the component still needs to accept an external ref explicitly, e.g. cross-version code). In React 18/17, wrap the component in React.forwardRef and pass ref: [forwardedRef, internalRef].
  • enabled: false: skips most internal hook logic and returns null — for conditionally-rendered custom components without an extra if wrapper.
  • Radix migration: Radix's asChild + <Slot.Root> pattern maps directly to useRender({ defaultTagName, render, props }) — no Slot component needed, render replaces the child-element convention entirely.
  • Polymorphism caveat: render is designed primarily for composing behavior/event handlers onto the same default tag. Rendering a genuinely different tag (e.g. swapping a <button> for a <div>) needs an explicit signal for tag-specific default props that wouldn't be valid on the new element (e.g. type="button") — this is exactly why Base UI's own Button exposes nativeButton rather than inferring it from render.

Code Examples

/* Minimal custom component with a render prop (React 19) */
interface TextProps extends useRender.ComponentProps<'p'> {}
function Text({ render, ...props }: TextProps) {
  return useRender({
    defaultTagName: 'p',
    render,
    props: mergeProps<'p'>({ className: styles.Text }, props),
  });
}
<Text render={<strong />}>Text as a strong tag</Text>
/* With state passed to a function-form render prop, React 18/17 */
const Text = React.forwardRef(function Text({ render, ...props }: TextProps, forwardedRef) {
  const internalRef = React.useRef<HTMLElement | null>(null);
  return useRender({
    defaultTagName: 'p',
    ref: [forwardedRef, internalRef],
    props,
    render,
  });
});
/* Radix asChild equivalent */
// Radix:  const Comp = asChild ? Slot.Root : 'button'; return <Comp {...props} />;
// Base UI:
function Button({ render, ...props }) {
  return useRender({ defaultTagName: 'button', render, props });
}
<Button render={<MyButton className="primary" />}>Submit</Button>;
  • What it demonstrates: building a Base-UI-style custom component (own render prop, own default tag, own ref-merging) is a ~10-line pattern once useRender + mergeProps are known — this is how to make in-house components compose the same way the library's own parts do.

Key Takeaways

  1. Reach for useRender when building an in-house component meant to compose with Base UI conventions (accept render, expose data-* state attributes) — don't hand-roll a parallel asChild-style API.
  2. Type external props with useRender.ComponentProps<Tag>, internal default props with useRender.ElementProps<Tag> — mixing these up is the most common typing mistake when wrapping the hook.
  3. Match the ref pattern to the target React version: ref: internalRef (or an array) directly in React 19, React.forwardRef + ref: [forwardedRef, internalRef] in React 18/17.

Connects To

  • ch007 (Composition): the consumer-facing render prop this hook implements from the component-author side.
  • ch050 (mergeProps): required for correctly combining a custom component's default props with consumer-supplied ones.
  • ch015 (Button): nativeButton is the real-world example of the "polymorphism needs an explicit signal" caveat above.