-
Notifications
You must be signed in to change notification settings - Fork 0
Form Handling
@nrbx/react includes a custom form system for Roblox UI that mirrors React 19's form actions and form state APIs. It is designed to feel familiar to web React developers, while adapting to Roblox primitives such as TextBox, TextButton, ScrollingFrame, and UIListLayout.
Form handling in @nrbx/react is intentionally lightweight and explicit:
-
<form>manages submission and form state - Inputs collect values into a
FormDataobject -
actiondrives React 19-style submit behavior -
useFormStatus()exposes pending state and submit metadata -
useActionState()manages server-like action state without a browser server -
form.reset()resets all fields in a form
This page documents the supported form primitives and the expected usage patterns.
The <form> component wraps form controls and handles submission. It can receive either a React 19-style action prop or a classic onSubmit handler.
function SearchForm() {
return (
<form
action={async (formData: FormData) => {
const query = formData.get("query");
print("searching for:", query);
}}
onSubmit={(event) => {
print("traditional submit handler");
}}
>
<input name="query" type="search" placeholder="Search items" />
<button type="submit">Search</button>
</form>
);
}Supported form props:
action?: (formData: FormData) => void | Promise<void>onSubmit?: (event: FormEvent) => voidmethod?: "get" | "post"name?: stringclassName?: stringchildren?: ReactNode
When action is present, it is treated as the primary form action. onSubmit is still available for traditional event-driven logic.
Forms also support:
-
form.reset()to clear field values - submission state tracking via
useFormStatus() - synchronous read access to values through
FormData
<input> maps to a Roblox TextBox for text-like values and supports the common web input types you would expect in a form.
Supported input types:
-
type="text"(default) type="number"type="password"type="search"type="checkbox"type="radio"type="color"type="date"type="range"
function ProfileForm() {
return (
<form>
<input name="displayName" type="text" placeholder="Display name" />
<input name="age" type="number" min={0} max={120} />
<input name="password" type="password" placeholder="Password" />
<input name="newsletter" type="checkbox" defaultChecked />
<input name="theme" type="radio" value="dark" defaultChecked />
<input name="accent" type="color" defaultValue="#3b82f6" />
<input name="birthday" type="date" />
<input name="volume" type="range" min={0} max={100} defaultValue={60} />
</form>
);
}<textarea> creates a multi-line text field and is useful for comments, bios, and longer payloads.
function FeedbackForm() {
return (
<form>
<textarea
name="message"
placeholder="Tell us what you think"
defaultValue=""
/>
</form>
);
}<select> is a dropdown field and <option> renders as selectable entries in the underlying UI list. This is especially useful for enums, filters, and preference selectors.
function TeamSelect() {
return (
<form>
<label htmlFor="team">Team</label>
<select name="team" defaultValue="blue">
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</form>
);
}Under the hood, the options map to Roblox UI items in the form's layout. The selected value is still read via formData.get("team").
<label> associates visible text with a specific input, making forms easier to understand and easier to use.
function EmailField() {
return (
<label>
<span>Email</span>
<input name="email" type="text" placeholder="name@example.com" />
</label>
);
}<fieldset> groups related fields and <legend> provides a caption for the group.
<form>
<fieldset>
<legend>Account</legend>
<label>
<span>Username</span>
<input name="username" />
</label>
<label>
<span>Password</span>
<input name="password" type="password" />
</label>
</fieldset>
</form>Buttons are the main submission triggers. Use type="submit" for form submission and type="reset" for clearing the form.
<form>
<button type="reset">Clear</button>
<button type="submit">Save</button>
</form>@nrbx/react supports both web-style patterns: controlled inputs with value + onChange, and uncontrolled inputs with defaultValue.
Use controlled inputs when the value is derived from component state.
function ControlledName() {
const [name, setName] = useState("");
return (
<input
name="name"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
);
}Use uncontrolled inputs when you want the Roblox form to manage its own value state until submission.
function UncontrolledName() {
return (
<input
name="name"
defaultValue="Guest"
/>
);
}In practice, controlled inputs are best when you need validation or display while the user types. Uncontrolled inputs are simpler when you only need the final value on submit.
The action prop accepts a function that receives a FormData instance. This mirrors the React 19 form action model and is the recommended pattern for complex submissions.
function LoginForm() {
const [state, submitAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
if (!username || !password) {
return { error: "All fields required" };
}
await Promise.delay(1);
return { success: `Welcome, ${username}!` };
},
{ error: undefined, success: undefined }
);
return (
<form action={submitAction} className="flex flex-col gap-4 p-4">
<label>
<span className="text-sm">Username</span>
<input name="username" placeholder="Enter username" />
</label>
<label>
<span className="text-sm">Password</span>
<input name="password" type="password" placeholder="Enter password" />
</label>
{state.error && <span className="text-red-500">{state.error}</span>}
{state.success && <span className="text-green-500">{state.success}</span>}
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
className={cn("bg-blue-500 text-white p-2 rounded", pending && "opacity-50")}
>
{pending ? "Submitting..." : "Login"}
</button>
);
}This is the preferred pattern when you want to centralize validation, async processing, and completion state.
You can also use a plain onSubmit event handler for a more traditional imperative flow:
function BasicForm() {
return (
<form
onSubmit={(event) => {
const formData = new FormData(event.currentTarget);
const email = formData.get("email");
print("login request for:", email);
}}
>
<input name="email" type="text" />
<button type="submit">Continue</button>
</form>
);
}useFormStatus() reads the status of the nearest parent form and returns a small status object.
const status = useFormStatus();
// {
// pending: boolean,
// data: FormData | null,
// method: string,
// action: string,
// }Typical usage:
function SaveButton() {
const { pending, data } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save changes"}
</button>
);
}This is useful for:
- disabling submit buttons while work is in progress
- showing loading indicators
- reading the last submitted
FormData - inspecting the
methodor action target associated with the form
useActionState(action, initialState) is the recommended hook for stateful form submissions. It gives you a tuple similar to React 19's action state API:
const [state, submitAction, isPending] = useActionState(
async (previousState, formData) => {
// validate and process
return { ... }
},
initialState
);The returned tuple is:
-
state: the current action result -
submitAction: the function passed to the formactionprop -
isPending: whether the current action is still running
Example:
function SignupForm() {
const [state, submitAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
if (!email || !password) {
return { errors: ["Email and password are required."] };
}
await Promise.delay(0.5);
return { success: `Signed up as ${email}` };
},
{ errors: [], success: "" }
);
return (
<form action={submitAction}>
<input name="email" type="text" />
<input name="password" type="password" />
<button type="submit" disabled={isPending}>
{isPending ? "Signing up..." : "Sign up"}
</button>
{state.errors?.length > 0 && (
<ul>
{state.errors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
{state.success && <p>{state.success}</p>}
</form>
);
}This is especially useful for forms that need structured success or error state after a submit.
Validation is implemented by your component logic. @nrbx/react does not enforce browser-style validation rules automatically; instead, you validate the values you receive in FormData.
async function handleSubmit(formData: FormData) {
const username = formData.get("username");
const email = formData.get("email");
if (typeof username !== "string" || username.trim().length < 3) {
throw "Username must be at least 3 characters.";
}
if (typeof email !== "string" || !email.includes("@")) {
throw "Please enter a valid email.";
}
print("validated:", username, email);
}Common validation approaches:
- validate inside the
actionfunction - validate in
onSubmitbefore network work begins - validate as the user types with controlled
value+onChange - display validation results using local component state
A typical pattern is to keep validation close to the action and render inline errors based on the returned state.
FormData is the main way to read values out of a form. The custom runtime supports the same core patterns you expect from web form handling.
function handleLogin(formData: FormData) {
const username = formData.get("username");
const password = formData.get("password");
const tags = formData.getAll("tag");
for (const [key, value] of formData.entries()) {
print(key, value);
}
if (typeof username !== "string") {
return;
}
if (typeof password !== "string") {
return;
}
print("username:", username);
print("password length:", password.length);
print("tags:", tags);
}Common methods:
-
formData.get(key)— returns the first matching value -
formData.getAll(key)— returns every value for a repeated key -
formData.entries()— iterates key/value pairs -
formData.has(key)— checks for a key -
formData.set(key, value)— updates a field value -
formData.append(key, value)— adds another value for a key
This is especially useful when working with checkboxes, radios, multi-selects, and repeated fields.
form.reset() clears the form's current values and restores default states.
function ResettableForm() {
const formRef = useRef<any>(null);
return (
<form ref={formRef}>
<input name="username" defaultValue="Guest" />
<textarea name="notes" defaultValue="Draft" />
<button type="button" onClick={() => formRef.current?.reset()}>
Reset form
</button>
</form>
);
}This is useful for forms where the user wants to discard edits or revert a draft after validation failure.
@nrbx/react aims to mirror the React 19 form model, but Roblox UI does not have a perfect browser DOM equivalent. Some browser-only capabilities are intentionally unavailable or only partially supported.
Current limitations and caveats:
- no file input support (
<input type="file">) - no
datetime-localinput - no browser-native HTML validation engine
- no automatic network submission to a server
- no built-in browser form encoding beyond the custom
FormDataimplementation -
color,date, andrangeinputs map to Roblox UI capabilities and may vary by runtime behavior - unsupported HTML form semantics should be replaced with explicit component logic
In other words, the API is intentionally ergonomic, but validation, serialization, and submission behavior still live in your app code.
For larger UI systems, you will often want to build reusable form field wrappers.
interface TextFieldProps {
label: string;
name: string;
type?: "text" | "number" | "password" | "search";
placeholder?: string;
defaultValue?: string;
}
function TextField({
label,
name,
type = "text",
placeholder,
defaultValue,
}: TextFieldProps) {
return (
<label className="flex flex-col gap-1">
<span>{label}</span>
<input
name={name}
type={type}
placeholder={placeholder}
defaultValue={defaultValue}
/>
</label>
);
}Then use it like this:
function AccountForm() {
return (
<form action={async (formData: FormData) => {
print("account form submit:", formData.get("username"));
}}>
<TextField label="Username" name="username" placeholder="Choose a username" />
<TextField label="Password" name="password" type="password" placeholder="••••••••" />
<button type="submit">Create account</button>
</form>
);
}This pattern is useful for building consistent, typed, reusable form libraries inside your Roblox UI app.
- Prefer
actionfor React 19-style form submission flows - Use
useActionState()when you need a server-like success/error state - Use
useFormStatus()to disable or label the submit button while pending - Validate in a single place, usually near the action or submit handler
- Use controlled inputs for validation-heavy or dynamic UI
- Use uncontrolled inputs for simpler forms or default-specified values
- Cast values from
FormDatacarefully, especially for numbers and booleans
@nrbx/react keeps the mental model close to React 19 while adapting to Roblox's UI system, making it a natural fit for game UIs, admin panels, settings screens, and in-game forms.