---
title: useRender
subtitle: Hook for enabling a render prop in custom components.
description: Hook for enabling a render prop in custom components.
---

> FineSoft Components documentation. Independent Stencil port; not the official Base UI website.
>
> React API text and `@base-ui/react` examples are upstream references. FineSoft Stencil examples use `finesoft-components`. Do not treat the two packages as interchangeable.

# useRender

Hook for enabling a render prop in custom components.

The `useRender` hook lets you build custom components that provide a `render` prop to override the default rendered element.

## Examples

A `render` prop for a custom Text component lets consumers use it to replace the default rendered `p` element with a different tag or component.

## Demo

### CSS Modules

This example shows how to implement the component using CSS Modules.

```css
/* index.module.css */
.Text {
  font-size: 0.875rem;
  line-height: 1rem;
  color: oklch(14.5% 0 0deg);

  @media (prefers-color-scheme: dark) {
    color: white;
  }

  strong& {
    font-weight: 700;
  }
}
```

```tsx
/* docs-comparison.tsx */
import { StencilComparison } from 'docs/src/components/StencilComparison';
import { renderVNodeToString } from 'finesoft-components/define-custom-elements';
import ReactExample from './react-render';
import { createExample } from './stencil-render';
import StencilPreview from './stencil-preview';
export default function Comparison() {
  return (
    <StencilComparison
      component="use-render"
      react={<ReactExample />}
      stencil={null}
      clientStencil={<StencilPreview />}
      renderSource={() => renderVNodeToString(createExample().render())}
    />
  );
}
```

```tsx
/* react-render.tsx */
'use client';
import { useRender } from '@base-ui/react/use-render';
import { mergeProps } from '@base-ui/react/merge-props';
import styles from './index.module.css';

interface TextProps extends useRender.ComponentProps<'p'> {}

function Text(props: TextProps) {
  const { render, ...otherProps } = props;

  const element = useRender({
    defaultTagName: 'p',
    render,
    props: mergeProps<'p'>({ className: styles.Text }, otherProps),
  });

  return element;
}

export default function ExampleText() {
  return (
    <div>
      <Text>Text component rendered as a paragraph tag</Text>
      <Text render={<strong />}>Text component rendered as a strong tag</Text>
    </div>
  );
}
```

```ts
/* stencil-render.ts */
import { h, mergeProps, useRender } from 'finesoft-components/define-custom-elements';
import './stencil.css';

function Text({ render, ...props }: useRender.ComponentProps<'p'>) {
  return useRender({ defaultTagName: 'p', render, props: mergeProps<'p'>({ class: { ['demo-utils-use-render-render-text']: true } }, props) });
}

export function createExample(_update?: () => void) {
  return {
    render: () =>
      h('div', {}, Text({ children: 'Text component rendered as a paragraph tag' }), Text({ render: h('strong', {}), children: 'Text component rendered as a strong tag' })),
  };
}
```

```tsx
/* stencil-preview.tsx */
'use client';
import * as React from 'react';
import { render } from 'finesoft-components/define-custom-elements';
import { createExample } from './stencil-render';

export default function StencilPreview() {
  const host = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const container = host.current!;
    const example = createExample(() => render(example.render(), container));
    render(example.render(), container);
    // Stencil accepts null for unmount; its current type declaration requires VNode.
    return () => {
      render(null!, container);
    };
  }, []);
  return <div ref={host} style={{ display: 'contents' }} />;
}
```

```css
/* stencil.css */
/* Example layout and visual exceptions. Default component styles come from the theme. */
@layer fs-theme {
  .demo-utils-use-render-render-text {
    font-size: var(--fs-font-size-sm);
    line-height: 1rem;
    color: var(--fs-color-foreground);

    strong& {
      font-weight: var(--fs-font-weight-bold);
    }
  }
}
```

The callback version of the `render` prop enables more control of how props are spread, and also passes the internal `state` of a component.

## Demo

### CSS Modules

This example shows how to implement the component using CSS Modules.

```css
/* index.module.css */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  height: 2rem;
  padding: 0 0.75rem;
  margin: 0;
  outline: 0;
  border: 1px solid oklch(14.5% 0 0deg);
  border-radius: 0;
  background-color: white;
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 400;
  line-height: 1.25rem;
  color: oklch(14.5% 0 0deg);
  -webkit-user-select: none;
  user-select: none;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
  }

  @media (hover: hover) {
    &:hover {
      background-color: oklch(97% 0 0deg);

      @media (prefers-color-scheme: dark) {
        background-color: oklch(26.9% 0 0deg);
      }
    }
  }

  &:active {
    background-color: oklch(92.2% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(37.1% 0 0deg);
    }
  }

  &:focus-visible {
    outline: 2px solid oklch(14.5% 0 0deg);
    outline-offset: -1px;

    @media (prefers-color-scheme: dark) {
      outline-color: white;
    }
  }
}

.count {
  display: inline-block;
  min-width: 2ch;
  text-align: end;
  font-variant-numeric: tabular-nums;
}

.suffix {
  margin-left: 0.5rem;
  padding-left: 0.5rem;
  border-left: 1px solid currentColor;
  font-size: 0.75rem;
  font-weight: 700;
  line-height: 1rem;
  text-transform: uppercase;
}
```

```tsx
/* docs-comparison.tsx */
import { StencilComparison } from 'docs/src/components/StencilComparison';
import { renderVNodeToString } from 'finesoft-components/define-custom-elements';
import ReactExample from './react-render-callback';
import { createExample } from './stencil-render-callback';
import StencilPreview from './stencil-preview';
export default function Comparison() {
  return (
    <StencilComparison
      component="use-render"
      react={<ReactExample />}
      stencil={null}
      clientStencil={<StencilPreview />}
      renderSource={() => renderVNodeToString(createExample().render())}
    />
  );
}
```

```tsx
/* react-render-callback.tsx */
'use client';
import * as React from 'react';
import { useRender } from '@base-ui/react/use-render';
import { mergeProps } from '@base-ui/react/merge-props';
import styles from './index.module.css';

interface CounterState {
  odd: boolean;
}

interface CounterProps extends useRender.ComponentProps<'button', CounterState> {}

function Counter(props: CounterProps) {
  const { render, ...otherProps } = props;

  const [count, setCount] = React.useState(0);
  const odd = count % 2 === 1;
  const state = React.useMemo(() => ({ odd }), [odd]);

  const defaultProps: useRender.ElementProps<'button'> = {
    'className': styles.Button,
    'type': 'button',
    'children': (
      <React.Fragment>
        Counter: <span className={styles.count}>{count}</span>
      </React.Fragment>
    ),
    'onClick'() {
      setCount(prev => prev + 1);
    },
    'aria-label': `Count is ${count}, click to increase.`,
  };

  const element = useRender({
    defaultTagName: 'button',
    render,
    state,
    props: mergeProps<'button'>(defaultProps, otherProps),
  });

  return element;
}

export default function ExampleCounter() {
  return (
    <Counter
      render={(props, state) => (
        <button {...props}>
          {props.children}
          <span className={styles.suffix}>{state.odd ? '👎' : '👍'}</span>
        </button>
      )}
    />
  );
}
```

```ts
/* stencil-render-callback.ts */
import { h, mergeProps, useRender } from 'finesoft-components/define-custom-elements';
import './stencil.css';

export function createExample(update: () => void = () => {}) {
  let count = 0;
  function Counter({ render, ...props }: useRender.ComponentProps<'button', { odd: boolean }>) {
    return useRender({
      defaultTagName: 'button',
      render,
      state: { odd: count % 2 === 1 },
      props: mergeProps<'button'>(
        {
          'class': { ['demo-utils-use-render-render-callback-button']: true },
          'type': 'button',
          'children': ['Counter: ', h('span', { class: { 'demo-utils-use-render-render-callback-count': true } }, count)],
          'onClick'() {
            count += 1;
            update();
          },
          'aria-label': `Count is ${count}, click to increase.`,
        },
        props,
      ),
    });
  }
  return {
    render: () =>
      Counter({
        render: (props, state) => h('button', props, props.children, h('span', { class: { 'demo-utils-use-render-render-callback-suffix': true } }, state.odd ? '👎' : '👍')),
      }),
  };
}
```

```tsx
/* stencil-preview.tsx */
'use client';
import * as React from 'react';
import { render } from 'finesoft-components/define-custom-elements';
import { createExample } from './stencil-render-callback';

export default function StencilPreview() {
  const host = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const container = host.current!;
    const example = createExample(() => render(example.render(), container));
    render(example.render(), container);
    // Stencil accepts null for unmount; its current type declaration requires VNode.
    return () => {
      render(null!, container);
    };
  }, []);
  return <div ref={host} style={{ display: 'contents' }} />;
}
```

```css
/* stencil.css */
/* Example layout and visual exceptions. Default component styles come from the theme. */
@layer fs-theme {
  .demo-utils-use-render-render-callback-button {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--fs-space-1);
    height: var(--fs-control-height);
    padding: 0 var(--fs-space-3);
    margin: 0;
    outline: 0;
    border: var(--fs-border-width) solid var(--fs-color-foreground);
    border-radius: var(--fs-border-radius);
    background-color: var(--fs-color-surface);
    font-family: var(--fs-font-family, inherit);
    font-size: var(--fs-font-size-sm);
    font-weight: var(--fs-font-weight-normal);
    line-height: var(--fs-line-height-sm);
    color: var(--fs-color-foreground);
    -webkit-user-select: none;
    user-select: none;

    @media (hover: hover) {
      &:hover {
        background-color: var(--fs-color-hover);
      }
    }

    &:active {
      background-color: var(--fs-color-active);
    }

    &:focus-visible {
      outline: var(--fs-focus-width) solid var(--fs-color-foreground);
      outline-offset: -1px;
    }
  }

  .demo-utils-use-render-render-callback-count {
    display: inline-block;
    min-width: 2ch;
    text-align: end;
    font-variant-numeric: tabular-nums;
  }

  .demo-utils-use-render-render-callback-suffix {
    margin-left: 0.5rem;
    padding-left: 0.5rem;
    border-left: 1px solid currentColor;
    font-size: 0.75rem;
    font-weight: 700;
    line-height: 1rem;
    text-transform: uppercase;
  }
}
```

## Merging props

The `mergeProps` function merges two or more sets of React props together. It safely merges three types of props:

1. Event handlers, so that all are invoked
2. `className` strings
3. `style` properties

`mergeProps` merges objects from left to right, so that subsequent objects' properties in the arguments overwrite previous ones. Merging props is useful when creating custom components, as well as inside the callback version of the `render` prop for any Base UI component.

```tsx title="Using mergeProps in the render callback"
import { mergeProps } from '@base-ui/react/merge-props';
import styles from './index.module.css';

function Button() {
  return (
    <Component
      render={(props, state) => (
        <button
          {...mergeProps<'button'>(props, {
            className: styles.Button,
          })}
        />
      )}
    />
  );
}
```

## Merging refs

When building custom components, you often need to control a ref internally while still letting external consumers pass their own—merging refs lets both parties have access to the underlying DOM element. The `ref` option in `useRender` enables this, which holds an array of refs to be merged together.

In React 19, `React.forwardRef()` is not needed when building primitive components, as the external ref prop is already contained inside `props`. Your internal ref can be passed to `ref` to be merged with `props.ref`:

```tsx title="React 19"
function Text({ render, ...props }: TextProps) {
  const internalRef = React.useRef<HTMLElement | null>(null); {/* @highlight-text "internalRef" */}

  const element = useRender({
    defaultTagName: 'p',
    {/* @highlight-start */}
    ref: internalRef, {/* @highlight-text "internalRef" */}
    {/* @highlight-end */}
    props,
    render,
  });

  return element;
}
```

In older versions of React, you need to use `React.forwardRef()` and add the forwarded ref to the `ref` array along with your own internal ref.

The [examples](/react/utils/use-render.md) above assume React 19, and should be modified to use `React.forwardRef()` to support React 18 and 17.

```tsx title="React 18 and 17"
const Text = React.forwardRef(function Text(
  { render, ...props }: TextProps,
  forwardedRef: React.ForwardedRef<HTMLElement>, //@highlight-text "forwardedRef"
) {
  const internalRef = React.useRef<HTMLElement | null>(null); // @highlight-text "internalRef"

  const element = useRender({
    defaultTagName: 'p',
    // @highlight-start
    // @highlight-text "internalRef" "forwardedRef"
    ref: [forwardedRef, internalRef],
    // @highlight-end
    props,
    render,
  });

  return element;
});
```

## TypeScript

To type props, there are two interfaces:

- `useRender.ComponentProps` for a component's external (public) props. It types the `render` prop and HTML attributes.
- `useRender.ElementProps` for the element's internal (private) props. It types HTML attributes alone.

```tsx title="Typing props"
// @highlight
interface ButtonProps extends useRender.ComponentProps<'button'> {}

function Button({ render, ...props }: ButtonProps) {
  // @highlight
  const defaultProps: useRender.ElementProps<'button'> = {
    className: styles.Button,
    type: 'button',
    children: 'Click me',
  };

  const element = useRender({
    defaultTagName: 'button',
    render,
    props: mergeProps<'button'>(defaultProps, props),
  });

  return element;
}
```

## Migrating from Radix UI

Radix UI uses an `asChild` prop, while Base UI uses a `render` prop. Learn more about how composition works in Base UI in the [composition guide](/react/handbook/composition.md).

In Radix UI, the `Slot` component lets you implement an `asChild` prop.

```jsx title="Radix UI Slot component"
import { Slot } from 'radix-ui';

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot.Root : 'button';
  return <Comp {...props} />;
}

// Usage
<Button asChild>
  <MyButton className="primary">Submit</MyButton>
</Button>;
```

In Base UI, `useRender` lets you implement a `render` prop. The example below is the equivalent implementation to the Radix example above.

```jsx title="Base UI render prop"
import { useRender } from '@base-ui/react/use-render';

function Button({ render, ...props }) {
  return useRender({
    defaultTagName: 'button',
    render,
    props,
  });
}

// Usage
<Button render={<MyButton className="primary" />}>Submit</Button>;
```

## Render prop and polymorphism

The `render` prop is primarily designed for composing event handlers and behavioral props. In most cases it should render the same tag as the default element.

Using `render` for polymorphism (rendering a different tag) requires more care, as some default props may not be valid on the new element. For example, `type="button"` is only valid on a `<button>`. Since the component can't know what element `render` will produce at render time and before hydration, props like these need an explicit signal. This is why Base UI's [Button](/react/components/button.md) provides a `nativeButton` prop to control which defaults are applied.

## Stencil API

```ts
import { h, useRender } from 'finesoft-components/use-render';

const element = useRender({
  defaultTagName: 'button',
  state: { active: true },
  props: { children: 'Action' },
  render: h('a', { href: '/details' }),
});
```

`useRender` returns a Stencil VNode. Call it from a Stencil component's `render()` method, or mount the result with the public `render(vnode, container)` function. It is not a React hook. For native VNode SSR, use `renderVNodeToString(vnode)` from the same entry; custom-element declarations can then pass through `finesoft-components/hydrate`. The examples use this serializer and the same consumer render function on the server and in the browser.

| Parameter                | Stencil contract                                                      |
| ------------------------ | --------------------------------------------------------------------- |
| `render`                 | Stencil VNode, or `(props, state) => VNode`                           |
| `defaultTagName`         | Native tag name; defaults to `div`                                    |
| `createElement`          | Optional local Stencil `h` factory for a separately built application |
| `props`                  | Native Stencil props; events use the `mergeProps` contract            |
| `state`                  | Passed to the callback and converted to `data-*` attributes           |
| `stateAttributesMapping` | Per-state mappings returning attributes or `null`                     |
| `ref`                    | Object ref, callback ref, or an array of refs                         |
| `enabled`                | `false` returns `null` without calling the render callback            |

Boolean `true` state becomes an empty attribute; falsy values are omitted. Attribute names use the lowercased state key. Explicit props override generated attributes. Default buttons use `type="button"`, and default images use `alt=""`; explicit props can override these defaults.

VNode overrides merge classes, styles and handlers and retain their own children. Callback overrides receive the resolved props and state directly. Object and callback refs from `props`, `render` and `ref` are combined. Callback cleanup functions run on replacement or unmount; legacy callbacks receive `null`. Stable refs stay attached across updates.

When building your own Stencil component in another project, use a local factory backed by that project's `h`. Stencil detects render capabilities from local `h()` calls: the dynamic props argument retains event, ref, class and style support, and the explicit SVG branch retains SVG support. Passing only an imported `h` reference does not tell the consuming compiler which features this utility needs. Standalone consumers using the package's `h` and `render` use the default factory.

```ts
import { h } from '@stencil/core';
import { useRender, type VNodeFactory } from 'finesoft-components/use-render';
const createElement: VNodeFactory = (tag, props, ...children) => (tag === 'svg' ? h('svg', props, ...children) : h(tag, props, ...children));
// Inside your component's render method:
return useRender({ createElement, defaultTagName: 'button', props: { children: 'Action' } });
```

The `useRender.ComponentProps`, `ElementProps`, `Parameters`, `RenderProp`, `ReturnValue`, and `State` types use Stencil VNodes and native events. React elements, React component functions and React hooks are not accepted as Stencil render overrides.

## API reference

### ComponentRenderFn

Shape of the render prop: a function that takes props to be spread on the element and component's state and returns a React element.

### useRender

Renders a Base UI element.

**useRender Parameters:**

| Parameter | Type                                                                           | Default | Description |
| :-------- | :----------------------------------------------------------------------------- | :------ | :---------- |
| params    | `useRender.Parameters<Record<string, unknown>, Element, boolean \| undefined>` | -       | -           |

**useRender Return Value:**

```tsx
type ReturnValue = ReactElement | null;
```

### useRender.State

```typescript
type useRenderState = {};
```

### useRender.ComponentProps

```typescript
type useRenderComponentProps<ElementType extends React.ElementType, TState = {}, RenderFunctionProps = HTMLProps> = React.ComponentPropsWithRef<ElementType> & {
  render?: ReactElement | ((props: RenderFunctionProps, state: TState) => ReactElement);
};
```

### useRender.ElementProps

```typescript
type useRenderElementProps = (React.PropsWithoutRef<Props> & React.RefAttributes<R | any>) | Props | React.ComponentProps<React.ElementType>;
```

### useRender.Parameters

```typescript
type useRenderParameters<TState, RenderedElementType extends Element, Enabled extends boolean | undefined> = {
  /** The React element or a function that returns one to override the default element. */
  render?: UseRenderRenderProp<TState>;
  /** The ref to apply to the rendered element. */
  ref?: React.Ref<RenderedElementType>[] | React.Ref<RenderedElementType>;
  /**
   * The state of the component, passed as the second argument to the `render` callback.
   * State properties are automatically converted to data-* attributes.
   */
  state?: TState;
  /**
   * Custom mapping for converting state properties to data-* attributes.
   * @example { isActive: (value) => (value ? { 'data-is-active': '' } : null) }
   */
  stateAttributesMapping?: StateAttributesMapping<TState>;
  /**
   * Props to be spread on the rendered element.
   * They are merged with the internal props of the component, so that event handlers
   * are merged, `className` strings and `style` properties are joined, while other external props overwrite the
   * internal ones.
   */
  props?: Record<string, unknown>;
  /**
   * If `false`, the hook will skip most of its internal logic and return `null`.
   * This is useful for rendering a component conditionally.
   * @default true
   */
  enabled?: boolean | undefined;
  /**
   * The default tag name to use for the rendered element when `render` is not provided.
   * @default 'div'
   */
  defaultTagName?: keyof React.JSX.IntrinsicElements;
};
```

### useRender.RenderProp

```typescript
type useRenderRenderProp<TState = Record<string, unknown>> = ReactElement | ((props: React.HTMLAttributes<any>, state: TState) => ReactElement);
```

### useRender.ReturnValue

```typescript
type useRenderReturnValue = ReactElement | null;
```

## Additional Types

### HTMLProps

```typescript
type HTMLProps<T = any> = React.HTMLAttributes<T> & { ref?: React.Ref<T> };
```

### UseRenderComponentProps

```typescript
type UseRenderComponentProps<ElementType extends React.ElementType, State = {}, RenderFunctionProps = HTMLProps> = React.ComponentPropsWithRef<ElementType> & {
  render?: ReactElement | ((props: RenderFunctionProps, state: State) => ReactElement);
};
```

### UseRenderElementProps

```typescript
type UseRenderElementProps = (React.PropsWithoutRef<Props> & React.RefAttributes<R | any>) | Props | React.ComponentProps<React.ElementType>;
```

### UseRenderParameters

```typescript
type UseRenderParameters<State, RenderedElementType extends Element, Enabled extends boolean | undefined> = {
  /** The React element or a function that returns one to override the default element. */
  render?: UseRenderRenderProp<State>;
  /** The ref to apply to the rendered element. */
  ref?: React.Ref<RenderedElementType> | React.Ref<RenderedElementType>[];
  /**
   * The state of the component, passed as the second argument to the `render` callback.
   * State properties are automatically converted to data-* attributes.
   */
  state?: State;
  /**
   * Custom mapping for converting state properties to data-* attributes.
   * @example { isActive: (value) => (value ? { 'data-is-active': '' } : null) }
   */
  stateAttributesMapping?: StateAttributesMapping<State>;
  /**
   * Props to be spread on the rendered element.
   * They are merged with the internal props of the component, so that event handlers
   * are merged, `className` strings and `style` properties are joined, while other external props overwrite the
   * internal ones.
   */
  props?: Record<string, unknown>;
  /**
   * If `false`, the hook will skip most of its internal logic and return `null`.
   * This is useful for rendering a component conditionally.
   * @default true
   */
  enabled?: boolean | undefined;
  /**
   * The default tag name to use for the rendered element when `render` is not provided.
   * @default 'div'
   */
  defaultTagName?: keyof React.JSX.IntrinsicElements;
};
```

### UseRenderRenderProp

```typescript
type UseRenderRenderProp<State = Record<string, unknown>> = ReactElement | ((props: React.HTMLAttributes<any>, state: State) => ReactElement);
```

### UseRenderReturnValue

```typescript
type UseRenderReturnValue = ReactElement | null;
```

### UseRenderState

```typescript
type UseRenderState = {};
```

```tsx title="Usage"
const element = useRender({
  // Input parameters
});
```
