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>
)
}Spread field: field only contains DOM-safe attributes (id, name, value, placeholder, required, aria-invalid), so <input {...field} /> is safe — no React "unknown prop" warnings. Translations (label, helperText) and setValue live in extra because they aren't DOM attributes.
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-entities — extra.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>
)Accessibility: when there is no visible label, the default inputs keep an accessible name via aria-label={label || node.data.name} on the control itself — so dropping the label never leaves an input unlabeled for screen readers.
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 nodeId → error 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
}}
/>Tip: Built-in validation (required fields, pattern) always runs first; your custom errors are merged on top and take precedence. Keys must match the node ID of the field. Use validationMode="onChange" to validate live as the user types.