Skip to main content

Best Practices

This guide outlines recommended patterns and practices when working with usetheform to help you build efficient, maintainable form solutions.

Breaking Down Complex Forms

For large forms, consider breaking them into logical sections using the Collection component:

ProfileForm.tsx
import { Form, Collection, Input } from "usetheform";

const ProfileForm = () => (
<Form onSubmit={values => console.log(values)}>
<Collection object name="personalInfo">
<Input name="firstName" />
<Input name="lastName" />
<Input type="email" name="email" />
</Collection>

<Collection object name="addressInfo">
<Input name="street" />
<Input name="city" />
<Input name="zipCode" />
</Collection>

<button type="submit">Submit</button>
</Form>
);

Using Reducers for Complex State Transformations

When you need to transform input data in complex ways, use reducers:

ShippingForm.tsx
import { Form, Input} from "usetheform";
import { ReducerFn } from "usetheform/types";

interface ShippingFormState {
name: string;
phone: string;
zip: string;
}

// Capitalize first letter of each word
const nameReducer: ReducerFn<string, ShippingFormState> = (
value: string = ""
) => value.replace(/\b\w/g, (char) => char.toUpperCase());

// Format phone number as (XXX) XXX-XXXX
const phoneReducer: ReducerFn<string, ShippingFormState> = (
value: string = ""
) => {
const digits = value.replace(/\D/g, "").slice(0, 10);
const parts = [
digits.slice(0, 3),
digits.slice(3, 6),
digits.slice(6, 10),
].filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return `(${parts[0]}`;
if (parts.length === 2) return `(${parts[0]}) ${parts[1]}`;
return `(${parts[0]}) ${parts[1]}-${parts[2]}`;
};

// Normalize ZIP code to 5-digit numeric string
const zipReducer: ReducerFn<string, ShippingFormState> = (value) =>
(value || "").replace(/\D/g, "").slice(0, 5);

const ShippingForm = () => {
return (
<Form<ShippingFormState> onSubmit={(state, isFormValid) => console.log("Form Data:", state, isFormValid)}>
<Input
type="text"
name="name"
reducers={[nameReducer]}
placeholder="e.g. john doe"
/>
<Input
type="text"
name="phone"
reducers={[phoneReducer]}
placeholder="e.g. (123) 456-7890"
/>
<Input
type="text"
name="zip"
reducers={[zipReducer]}
placeholder="e.g. 90210"
/>
<button type="submit">Submit</button>
</Form>
);
};

Validating a Form Using Joi Schema

You can pass an asyncValidator to the <Form> component to run Joi validation against the entire form state.

JoiFormExample.tsx
import { Form, Input, useAsyncValidation } from "usetheform";
import { ReducerFn, AsyncValidatorFn } from "usetheform/types";

const schema = Joi.object<UserForm>({
username: Joi.string().min(3).required(),
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
age: Joi.number().integer().min(18).required(),
});

const joiValidator: AsyncValidatorFn<UserForm> = async (value) => {
try {
await schema.validateAsync(value, { abortEarly: true });
return Promise.resolve("Success!");
} catch (err) {
const error = err as Joi.ValidationError;
return Promise.reject(error.details.map((d) => d.message).join("; "));
}
};

function JoiFormExample() {
const [asyncStatus, asyncValidationProp] = useAsyncValidation(joiValidator);
return (
<div>
<Form<UserForm>
{...asyncValidationProp}
onSubmit={(formData, isFormValid) => {
console.log(JSON.stringify(formData, null, 2), isFormValid);
}}
>
<div className="field">
<label>Username</label>
<Input<UserForm["username"]> name="username" type="text" />
</div>

<div className="field">
<label>Email</label>
<Input<UserForm["email"]> name="email" type="email" />
</div>

<div className="field">
<label>Age</label>
<Input<UserForm["age"]> name="age" type="number" />
</div>

<button type="submit">Submit</button>
</Form>
{asyncStatus.status === "asyncError" && (
<label>{asyncStatus.value}</label>
)}
</div>
);
}

File Upload with FormData

You can easily integrate file uploads in your form using FormData. Here's how to handle file input and submit the file to a backend API.

FileUploadForm.tsx
import { Form, Input, useForm } from "usetheform";
import { OnSubmitFormFn } from "usetheform/types";

type FileUploadFormState = {
file: File;
};

const UploadButton = () => {
const { submitted, isSubmitting, isValid } = useForm();
const disabled = isSubmitting || !isValid || submitted > 0;
return (
<button type="submit" disabled={disabled}>
{isSubmitting && submitted === 0 ? "Uploading..." : "Upload File"}
</button>
);
};

const handleSubmit: OnSubmitFormFn<FileUploadFormState> = async (formState) => {
const formData = new FormData();
formData.append("file", formState.file);

try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});

const result = await response.json();
return Promise.resolve(result);
} catch (error) {
return Promise.reject(error);
}
};

function FileUploadForm() {
return (
<Form<FileUploadFormState> onSubmit={handleSubmit}>
<Input type="file" name="file" placeholder="Select a File..." required />
<UploadButton />
</Form>
);
}

Conditional Validation

When building dynamic forms, it’s common to require a field only under certain conditions — such as when a checkbox is checked.

Here’s a best-practice example using usetheform:

ConditionalFormExample.tsx
import { Form, Input, useValidation } from "usetheform";
import { OnSubmitFormFn, OnChangeFormFn, ValidatorFn } from "usetheform/types";

interface MyFormState {
petCheckbox: boolean;
petName: string;
}

const required: ValidatorFn<MyFormState["petName"], MyFormState> = (
value,
formState
) =>
formState.petCheckbox
? value?.trim()
? undefined
: "Pet name is required"
: undefined;

export default function ConditionalFormExample() {
const onChange: OnChangeFormFn<MyFormState> = (formState) =>
console.log("ON_CHANGE:", formState);

const onSubmit: OnSubmitFormFn<MyFormState> = (formState) =>
console.log("ON_SUBMIT:", formState);

const [status, validation] = useValidation([required]);

return (
<Form<MyFormState> onSubmit={onSubmit} onChange={onChange}>
<label htmlFor="petCheckbox">Do you have a pet?</label>
<Input id="petCheckbox" name="petCheckbox" type="checkbox" />

<Input name="petName" type="text" touched {...validation} />
{status.errors?.petName && <span>{status.errors.petName}</span>}

<button type="submit">Submit</button>
</Form>
);
}

Multi-Step / Wizard Forms

Use multiple formStores and local state to manage step-based forms.

WizardForm.tsx
import { useState } from "react";
import { createFormStore } from "usetheform";
import { StepOne } from "./StepOne";
import { StepTwo } from "./StepTwo";
import { StepThree } from "./StepThree";

const [formStoreStepOne] = createFormStore();
const [formStoreStepTwo] = createFormStore();
const [formStoreStepThree] = createFormStore();

export const WizardForm = () => {
const [step, setStep] = useState<"step1" | "step2" | "step3">("step1");

const submitWizardForm = () => {
const data = {
stepOne: formStoreStepOne.getState(),
stepTwo: formStoreStepTwo.getState(),
stepThree: formStoreStepThree.getState(),
};
console.log("Form submitted:", data);
};

return (
<div>
<h2>Multi-Step Wizard</h2>
<span>Current Step: {step}</span>

{step === "step1" && (
<StepOne onNext={() => setStep("step2")} formStore={formStoreStepOne} />
)}
{step === "step2" && (
<StepTwo
onNext={() => setStep("step3")}
onPrev={() => setStep("step1")}
formStore={formStoreStepTwo}
/>
)}
{step === "step3" && (
<StepThree
onPrev={() => setStep("step2")}
onSubmit={submitWizardForm}
formStore={formStoreStepThree}
/>
)}
</div>
);
};
StepOne.tsx
import { Form, Input } from "usetheform";

interface StepOneFormState {
firstname: string;
lastname: string;
}

export const StepOne = ({ onNext, formStore }) => {
return (
<Form<StepOneFormState> formStore={formStore} onSubmit={onNext}>
<Input
name="firstname"
placeholder="First Name"
validators={[
(v) => (!v || v.trim() === "" ? "Required" : undefined),
(v) => (v && v.length < 3 ? "Min 3 chars" : undefined),
]}
/>
<Input name="lastname" placeholder="Last Name" />
<SubmitButton label="Next" />
</Form>
);
};
StepTwo.tsx
import { Form, Input, Select } from "usetheform";

interface StepTwoFormState {
age: number;
gender: "M" | "F" | "O";
}

export const StepTwo = ({ onNext, onPrev, formStore }) => {
return (
<Form<StepTwoFormState> formStore={formStore} onSubmit={onNext}>
<Input type="number" name="age" placeholder="Age" />
<Select name="gender" validators={[v => ["M", "F", "O"].includes(v) ? undefined : "Required"]}>
<option value="">Select gender</option>
<option value="M">Male</option>
<option value="F">Female</option>
<option value="O">Other</option>
</Select>
<button type="button" onClick={onPrev}>Prev</button>
<SubmitButton label="Next" />
</Form>
);
};
StepThree.tsx
import { Form, Input } from "usetheform";

interface StepThreeFormState {
address: string;
}

export const StepThree = ({ onPrev, onSubmit, formStore }) => {
return (
<Form<StepThreeFormState> formStore={formStore} onSubmit={onSubmit}>
<Input name="address" placeholder="Address" />
<button type="button" onClick={onPrev}>Prev</button>
<SubmitButton label="Submit" />
</Form>
);
};
SubmitButton.tsx
import { useForm } from "usetheform"

const SubmitButton = ({ label = "Next" }) => {
const { isSubmitting, isValid } = useForm();
const disabled = isSubmitting || !isValid;
return (
<button type="submit" disabled={disabled}>
{label}
</button>
);
};