Skip to content

Instantly share code, notes, and snippets.

@Frans06
Last active June 14, 2025 00:24
Show Gist options
  • Select an option

  • Save Frans06/62d47bd9494b9cfcd3a9ee72868c0617 to your computer and use it in GitHub Desktop.

Select an option

Save Frans06/62d47bd9494b9cfcd3a9ee72868c0617 to your computer and use it in GitHub Desktop.
Tanstack form custom Input field.
// Layout
import {
FieldApi,
functionalUpdate,
useField,
type DeepKeys,
type DeepValue,
type FieldAsyncValidateOrFn,
type FieldValidateOrFn,
type FormAsyncValidateOrFn,
type FormValidateOrFn,
} from "@tanstack/react-form";
import { UseFieldOptions } from "@tanstack/react-form/dist/esm/types";
import { FunctionComponent, useMemo, type ReactNode } from "react";
export type InputLayoutProps = {
title?: string | null;
required?: boolean;
icon?: ReactNode;
extraInfo?: ReactNode;
note?: ReactNode;
className?: string;
};
export type FieldComponentProps<
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName>,
TOnMount extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChange extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChangeAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlur extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlurAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmit extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmitAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TFormOnMount extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChange extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChangeAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnBlur extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnBlurAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnSubmit extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnSubmitAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnServer extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TParentSubmitMeta = {},
ExtendedApi = {},
> = {
children: (
fieldApi: FieldApi<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnServer,
TParentSubmitMeta
> &
ExtendedApi
) => ReactNode;
} & UseFieldOptions<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnServer,
TParentSubmitMeta
> &
ExtendedApi;
export const InputLayout = (<
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName>,
TOnMount extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChange extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChangeAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlur extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlurAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmit extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmitAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TFormOnMount extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChange extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChangeAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnBlur extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnBlurAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnSubmit extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnSubmitAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnServer extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TParentSubmitMeta = {},
>({
children,
...fieldOptions
}: FieldComponentProps<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnServer,
TParentSubmitMeta,
InputLayoutProps
>): ReactNode => {
const fieldApi = useField(fieldOptions);
const jsxToDisplay = useMemo(
() => functionalUpdate(children, fieldApi as any),
/**
* The reason this exists is to fix an issue with the React Compiler.
* Namely, functionalUpdate is memoized where it checks for `fieldApi`, which is a static type.
* This means that when `state.value` changes, it does not trigger a re-render. The useMemo explicitly fixes this problem
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
[children, fieldApi, fieldApi.state.value, fieldApi.state.meta]
);
return (
<div className="flex flex-1 flex-col">
<div
className={`${fieldOptions.icon || fieldOptions.title ? "pb-4" : ""} flex items-center gap-2`}
>
{fieldOptions.icon && (
<div className="text-nipon light:text-icon flex">
{fieldOptions.icon}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{fieldOptions.title && (
<label
htmlFor={fieldOptions.name}
className="text-inter text-nacre text-base font-medium"
>
{`${fieldOptions.title}${fieldOptions.required ? " *" : ""}`}
</label>
)}
{fieldOptions.extraInfo && (
<p className="text-inter text-smoke light:text-pearl text-base">
{fieldOptions.extraInfo}
</p>
)}
</div>
</div>
{jsxToDisplay}
{!fieldApi.state.meta.isValid && (
<em className="px-5 pt-2 text-red-700" role="alert">
{fieldApi.state.meta.errors.join(", ")}
</em>
)}
<div>
{fieldOptions.note && (
<p className="text-inter text-smoke-light light:font-medium pt-4 text-sm font-medium">
{fieldOptions.note}
</p>
)}
</div>
</div>
);
}) satisfies FunctionComponent<
FieldComponentProps<
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
>
>;
// Input field
import {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
} from "@tanstack/react-form";
import type {
HTMLInputTypeAttribute,
KeyboardEventHandler,
Ref,
WheelEvent,
} from "react";
import { twMerge } from "tailwind-merge";
import {
FieldComponentProps,
InputLayout,
InputLayoutProps,
} from "./InputLayout";
type InputProps = {
name: string;
inputRef?: Ref<HTMLInputElement>;
required?: boolean;
autoFocus?: boolean;
type: HTMLInputTypeAttribute;
placeholder?: string | null;
autoComplete?: string;
className?: string;
step?: string | number;
min?: string | number;
onKeyPress?: (e: KeyboardEventHandler) => void;
};
export const Input = <
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName> & string,
TOnMount extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChange extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnChangeAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlur extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnBlurAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmit extends
| undefined
| FieldValidateOrFn<TParentData, TName, TData> = undefined,
TOnSubmitAsync extends
| undefined
| FieldAsyncValidateOrFn<TParentData, TName, TData> = undefined,
TFormOnMount extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChange extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnChangeAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnBlur extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnBlurAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnSubmit extends undefined | FormValidateOrFn<TParentData> = undefined,
TFormOnSubmitAsync extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TFormOnServer extends
| undefined
| FormAsyncValidateOrFn<TParentData> = undefined,
TParentSubmitMeta = {},
>({
title,
name,
inputRef,
required = true,
autoFocus = false,
type,
placeholder,
autoComplete = type,
icon,
extraInfo,
note,
className,
step = 1,
min = 0,
form,
onKeyPress,
}: Omit<
FieldComponentProps<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnServer,
TParentSubmitMeta,
InputLayoutProps & InputProps
>,
"children"
>) => {
const extraPros = {
...(type === "number"
? {
onKeyPress: (event) => {
if (!/^\d*\.?\d*$/.test(event.key)) {
event.preventDefault();
}
},
}
: { onKeyPress }),
} as { onKeyPress: KeyboardEventHandler<HTMLInputElement> };
return (
<InputLayout
name={name}
title={title}
extraInfo={extraInfo}
required={required}
icon={icon}
note={note}
form={form}
>
{(field) => (
<input
{...extraPros}
id={field.name}
name={field.name}
value={field.state.value as TData}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value as TData)}
ref={inputRef}
data-testid={field.name}
required={required}
autoFocus={autoFocus}
type={type}
step={step}
min={min}
onWheel={(e: WheelEvent<HTMLInputElement>) => e.currentTarget.blur()}
placeholder={placeholder || ""}
autoComplete={autoComplete}
className={twMerge(
"text-inter bg-bokara-gray-light text-nacre caret-nacre placeholder:text-pearl box-border h-10 w-full rounded-full px-5 text-base font-normal outline-hidden",
className
)}
/>
)}
</InputLayout>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment