Command Palette

Search for a command to run...

Customization

Treege allows you to customize the rendered form components to match your design system.

Custom Components

Override default input components with your own implementations.

Component Props

An input renderer is a React component that receives a single props object with two keys: field (DOM-safe props you can spread straight onto an element) and extra (Treege-specific props — setters, translations, metadata). Being a real component, it can use hooks.

type InputRenderProps<T extends InputType = InputType> = {
  field: InputFieldProps<T>;
  extra: InputExtraProps<T>;
};
 
type InputRenderer<T extends InputType = InputType> = ComponentType<InputRenderProps<T>>;
 
// 1. DOM-safe props — spreadable onto a control: <input {...field} />
export type InputFieldProps<T extends InputType = InputType> = {
  /** Unique field id (nodeId). Use for the element `id`. */
  id: string;
  /** Field name (resolved: name > label > nodeId). */
  name: string;
  /** Current value (typed by input type when T is specified). */
  value: InputValueTypeMap[T];
  /** Translated placeholder (already processed with current language). */
  placeholder?: string;
  /** Whether the field is required. */
  required?: boolean;
  /** Set when the field has a validation error. */
  "aria-invalid"?: boolean;
};
 
// 2. Treege-specific props — NOT DOM attributes
export type InputExtraProps<T extends InputType = InputType> = {
  /** The node data for this input field. */
  node: Node<InputNodeData>;
  /** Update the input value (typed by input type when T is specified). */
  setValue: (value: InputValueTypeMap[T]) => void;
  /** Validation error message for this field (if any). */
  error?: string;
  /** Translated label (already processed with current language). */
  label?: string;
  /**
   * Resolved label component (your `components.inputLabel` or the default).
   * Renders nothing when there is no label. Reuse it to keep custom inputs
   * consistent: `<extra.InputLabel label={extra.label} required={extra.node.data.required} htmlFor={field.id} />`.
   */
  InputLabel: InputLabelRenderer;
  /** Translated helper text (already processed with current language). */
  helperText?: string;
  /**
   * Fields this input's dynamic options depend on that are not yet filled
   * (its unresolved `{{nodeId}}` template variables). Empty when none — use it
   * to hint the user which fields to complete before this input can load.
   */
  missingDependencies: { id: string; label: string }[];
  /** Current step's required fields that are still empty. */
  missingRequiredFields?: string[];
  /** Whether the form is currently being submitted. */
  isSubmitting?: boolean;
};

Example: Custom Text Input

import type { InputRenderProps } from "treege/renderer"
 
const CustomTextInput = ({ field, extra }: InputRenderProps<"text">) => {
  return (
    <div className="mb-4">
      <label className="block text-sm font-medium mb-1" htmlFor={field.id}>
        {extra.label}
        {extra.node.data.required && <span className="text-red-500 ml-1">*</span>}
      </label>
      <input
        {...field}
        type="text"
        onChange={(e) => extra.setValue(e.target.value)}
        className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      {extra.error && <p className="text-red-500 text-sm mt-1">{extra.error}</p>}
      {extra.helperText && !extra.error && (
        <p className="text-gray-500 text-sm mt-1">
          {extra.helperText}
        </p>
      )}
    </div>
  )
}

Using Custom Components

import { TreegeRenderer } from "treege/renderer"
import { CustomTextInput } from "./CustomTextInput"
import { CustomNumberInput } from "./CustomNumberInput"
 
<TreegeRenderer
  flow={flow as Flow}
  onSubmit={handleSubmit}
  components={{
    inputs: {
      text: CustomTextInput,
      number: CustomNumberInput,
    },
  }}
/>

Important: Using id and name Props

The id and name props are part of field and are automatically provided. Always use them for proper form behavior — spreading field carries them through:

const CustomInput = ({ field, extra }: InputRenderProps<"text">) => (
  <input
    {...field}        // id (accessibility) + name (form submission) + value + placeholder
    onChange={(e) => extra.setValue(e.target.value)}
  />
)

The name field is resolved with priority: node.data.name > node.data.label > node.id

Dependency Hints

When an input's dynamic options come from an API whose URL/query/body references another field — e.g. https://api.example.com/entities/{{plan_de_compte}}/sub-entitiesextra.missingDependencies lists the fields the user must fill first (with their translated labels). Use it to disable the control and explain why:

const CustomSelect = ({ field, extra }: InputRenderProps<"select">) => {
  const blocked = extra.missingDependencies.length > 0
 
  return (
    <div>
      <select {...field} disabled={blocked} onChange={(e) => extra.setValue(e.target.value)}>
        {/* options */}
      </select>
      {blocked && (
        <p className="text-amber-600 text-sm">
          Please fill in first: {extra.missingDependencies.map((d) => d.label).join(", ")}
        </p>
      )}
    </div>
  )
}

Custom Submit Button

The submit/continue button always lives in the step's action row (next to Back), whether submission comes from the end of the flow, the default Submit, or an explicit submit input node. Customize that single button — and its wrapper — via components.submitButton / components.submitButtonWrapper:

<TreegeRenderer
  flow={flow}
  onSubmit={handleSubmit}
  components={{
    submitButton: ({ label }) => (
      <button type="submit" className="btn-primary">
        {label || "Submit"}
      </button>
    ),
    submitButtonWrapper: ({ children, missingFields }) => (
      <div className="submit-wrapper">
        {children}
        {missingFields && missingFields.length > 0 && (
          <div className="tooltip">
            Please fill required fields: {missingFields.join(", ")}
          </div>
        )}
      </div>
    ),
  }}
/>

The submit Input Node

A submit input node is declarative: it marks where the flow submits and carries the optional custom label and submitConfig (HTTP submission settings). It renders no button of its own — the step renders the single submit button (reusing the node's label), so you never end up with two buttons.

This means you should not override components.inputs.submit to draw a button — use components.submitButton (above) instead, which controls the one button the step renders for every case.

Custom Input Label

Every default input renders its label through one shared component. Override it once via components.inputLabel to restyle every field label across the form (web and native) — no need to reimplement each input.

It receives { label, required, htmlFor } (plus id/className on web, style on native) and should render nothing when label is empty, so the technical name key never leaks into the form:

<TreegeRenderer
  flow={flow}
  components={{
    inputLabel: ({ label, required, htmlFor }) =>
      label ? (
        <label htmlFor={htmlFor} className="my-label">
          {label}
          {required && <span className="text-red-500"> *</span>}
        </label>
      ) : null,
  }}
/>

The resolved label component is also exposed to every input renderer as extra.InputLabel, so a custom input can reuse the same (overridable) label:

const CustomTextInput = ({ field, extra }: InputRenderProps<"text">) => (
  <div className="mb-4">
    <extra.InputLabel label={extra.label} required={extra.node.data.required} htmlFor={field.id} />
    <input {...field} onChange={(e) => extra.setValue(e.target.value)} />
  </div>
)

Custom Step (Multi-Step Forms)

When a flow contains Group nodes, the renderer automatically splits it into navigable steps. Override the default step layout (and its Back/Continue controls) via components.step:

<TreegeRenderer
  flow={flow}
  onSubmit={handleSubmit}
  components={{
    step: ({ label, children, canGoBack, isLastStep, canContinue, isSubmitting, onBack, onContinue }) => (
      <section>
        <h2>{label}</h2>
        {children}
        {canGoBack && <button onClick={onBack}>Back</button>}
        <button disabled={!canContinue || isSubmitting} onClick={onContinue}>
          {isSubmitting ? "Submitting..." : isLastStep ? "Submit" : "Continue"}
        </button>
      </section>
    ),
  }}
/>

Use canGoBack (rather than !isFirstStep) to decide whether to render the Back control: it is true on any step past the first, and on the first step when the consumer passes an onBack prop — in which case onBack steps back into the outer flow (e.g. a parent modal). The onBack/onContinue handlers already encapsulate that boundary logic, so the step component only decides whether to show the buttons.

The step also receives missingFields — the current step's required fields that are still empty (lines up with canContinue) — so you can show a tooltip explaining why the button is disabled, on every step. hasSubmitInput is true when the step contains an explicit submit node, and step exposes the step's ordered nodes (e.g. to read a submit node's custom label).

Custom Loading Skeleton

Pass isLoading to render a skeleton in place of the form (useful while the flow is being fetched), and customize it via components.loadingSkeleton:

<TreegeRenderer
  flow={flow}
  isLoading={isPending}
  components={{
    loadingSkeleton: () => <MyCustomSkeleton />,
  }}
/>

Custom Form Wrapper & UI Nodes

You can also override the outer form element and the UI node renderers (title, divider):

<TreegeRenderer
  flow={flow}
  components={{
    form: ({ children, onSubmit }) => (
      <form className="space-y-4" onSubmit={onSubmit}>
        {children}
      </form>
    ),
    ui: {
      title: ({ node }) => <h2 className="text-xl font-bold">{node.data.label}</h2>,
      divider: () => <hr className="my-6" />,
    },
  }}
/>

Custom Validation

Custom validation is handled through the validate prop — a single function that receives the current form values and the list of visible nodes, and returns a map of nodeIderror message. Returning an empty object means the form is valid.

Example: Email Validation

<TreegeRenderer
  flow={flow}
  onSubmit={handleSubmit}
  validate={(values, nodes) => {
    const errors: Record<string, string> = {}
 
    if (values.email && !values.email.endsWith("@company.com")) {
      errors.email = "Must use company email address"
    }
 
    return errors
  }}
/>

Example: Cross-Field Validation

<TreegeRenderer
  flow={flow}
  onSubmit={handleSubmit}
  validate={(values) => {
    const errors: Record<string, string> = {}
 
    if (values.password !== values.confirmPassword) {
      errors.confirmPassword = "Passwords must match"
    }
 
    return errors
  }}
/>