Forms and User Input Patterns
This guide documents our established patterns for handling forms and user input, using real examples from our codebase.
Form Components
Section titled “Form Components”Base Input Component
Section titled “Base Input Component”We have a reusable base input component with consistent styling and behavior:
export interface InputProps { className?: string; type?: "text" | "email" | "checkbox"; placeholder?: string; name?: string; required?: boolean | null; id?: string; onInput: (e: InputEvent) => void; onFocus?: () => void; value: string;}
export function Input(props: InputProps) { const { className = "", type = "text", placeholder = "", name = "", required = null, id = "", onInput, onFocus = () => {}, value, } = props;
return ( <input id={id} required type={type} name={name} placeholder={placeholder} onInput={onInput} onFocus={onFocus} value={value} class={cn( "rounded-pill border border-secondary focus:border-tertiary px-8 py-3 font-light", className, )} /> );}🤔 Why This Pattern?
- TypeScript interface for type safety
- Default prop values
- Consistent styling
- Accessibility attributes
Form Handling with Astro Actions
Section titled “Form Handling with Astro Actions”We use Astro actions for form handling, which provides built-in type safety, validation, and error handling. There are two main approaches:
1. HTML Form with Astro Actions
Section titled “1. HTML Form with Astro Actions”Use this approach for simple forms where progressive enhancement is sufficient:
---import { defineAction } from "astro:actions";import { z } from "astro/zod";
// Define the action and validationexport const newsletterAction = defineAction({ name: "newsletter", schema: z.object({ email: z.string().email("Please enter a valid email"), }),});
// Get action result to show feedbackconst result = Astro.getActionResult(newsletterAction);---
{/* Show success/error messages */}{ result?.error && ( <p mode="critical" class="text-primary"> {result.error.message} </p> )}{ result?.success && ( <p mode="success" class="text-primary"> Successfully subscribed! </p> )}
<form method="POST" action={newsletterAction.url}> <label for="email">Email</label> <input type="email" id="email" name="email" required class="border p-2" /> <button type="submit" class="btn btn-primary"> Subscribe </button></form>🤔 Why This Pattern?
- Works without JavaScript
- Built-in form validation
- Automatic error handling
- Progressive enhancement
2. SolidJS Form with Astro Actions
Section titled “2. SolidJS Form with Astro Actions”Use this approach when you need more interactive form handling:
import { defineAction } from "astro:actions";import { z } from "astro/zod";
export const newsletterAction = defineAction({ name: "newsletter", schema: z.object({ email: z.string().email("Please enter a valid email"), preferences: z.array(z.string()).optional() }), handler: async ({ email, preferences }) => { try { await subscribeToNewsletter({ email, preferences }); return { success: true, data: { message: "Successfully subscribed!" } }; } catch (err) { return { success: false, error: "Failed to subscribe. Please try again." }; } }});
// components/NewsletterForm.tsximport { createSignal, Show } from "solid-js";import { actions } from "astro:actions";
export const NewsletterForm = () => { const [email, setEmail] = createSignal(""); const [preferences, setPreferences] = createSignal([]); const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle"); const [message, setMessage] = createSignal("");
const handleSubmit = async (e: Event) => { e.preventDefault(); setStatus("loading");
try { const result = await actions.newsletter({ email: email(), preferences: preferences() });
if (result.error) { setStatus("error"); setMessage(result.error); return; }
setStatus("success"); setMessage(result.data.message); setEmail(""); setPreferences([]); } catch (err) { setStatus("error"); setMessage("An unexpected error occurred"); } };
return ( <form onSubmit={handleSubmit} class="space-y-4"> <div> <label for="email" class="block text-14 font-bold"> Email </label> <input id="email" type="email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} class="mt-1 block w-full rounded-md border-tertiary" required /> </div>
<div class="space-y-2"> <label class="block text-14 font-bold"> Preferences </label> <div class="space-y-1"> {["News", "Events", "Updates"].map(pref => ( <label class="flex items-center"> <input type="checkbox" value={pref} checked={preferences().includes(pref)} onChange={(e) => { const value = e.currentTarget.value; setPreferences(prev => e.currentTarget.checked ? [...prev, value] : prev.filter(p => p !== value) ); }} class="mr-2" /> {pref} </label> ))} </div> </div>
<Show when={status() !== "idle"}> <p mode={status() === "error" ? "critical" : "success"} class="text-primary" > {message()} </p> </Show>
<button type="submit" disabled={status() === "loading"} class="btn btn-primary w-full" > {status() === "loading" ? "Subscribing..." : "Subscribe"} </button> </form> );};
// Usage in Astro component:---import { NewsletterForm } from "#components/NewsletterForm";---
<NewsletterForm client:load />🤔 Why This Pattern?
- Rich interactive experience
- Real-time validation
- Type-safe action calls
- Controlled form state
Best Practices
Section titled “Best Practices”-
Action Definition
- Define schemas for validation
- Provide meaningful error messages
- Handle all error cases
- Return consistent response shapes
-
Form Implementation
- Use HTML forms for simple cases
- Use SolidJS for complex forms
- Show loading states
- Provide clear feedback
-
Error Handling
- Validate input server-side
- Return structured errors
- Show user-friendly messages
- Log errors appropriately
-
Security
- Validate all inputs
- Sanitize data
- Rate limit submissions
- Prevent CSRF attacks
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”❌ Don’t: Skip server-side validation
// Bad: Client-only validationconst handler = ({ email }) => { // No validation before use return subscribeUser(email);};✅ Do: Always validate server-side
// Good: Server-side validationconst handler = ({ email }) => { const result = schema.safeParse({ email }); if (!result.success) { return { error: result.error }; } return subscribeUser(result.data.email);};❌ Don’t: Use raw fetch for form submission
// Bad: Manual fetch implementationfetch("/api/subscribe", { method: "POST", body: JSON.stringify({ email }),});✅ Do: Use Astro actions
// Good: Type-safe action callsconst result = await actions.newsletter({ email: email(),});Decision Checklist
Section titled “Decision Checklist”When implementing forms, ask:
-
Form Complexity
- Is JavaScript required?
- Are there complex interactions?
- Is real-time validation needed?
-
Data Handling
- Is input validation defined?
- Are all error cases handled?
- Is feedback provided?
-
User Experience
- Are loading states shown?
- Is error feedback clear?
- Is success feedback shown?
-
Security
- Is data validated?
- Are inputs sanitized?
- Is rate limiting implemented?
Search Input Pattern
Section titled “Search Input Pattern”Here’s our pattern for search inputs:
interface TypesenseSearchBoxProps { classNames?: { root?: string; input?: string; }; label: string; id: string; searchUrl?: string;}
export function TypesenseSearchBox(props: TypesenseSearchBoxProps) { const [inputValue, setInputValue] = createSignal("");
return ( <form onSubmit={handleSubmit} onReset={handleReset} class={cn("relative", props.classNames?.root)} role="search" noValidate > <label for={props.id} class="sr-only"> {props.label} </label> <input id={props.id} type="search" auto-complete="off" auto-correct="off" autoCapitalize="off" spell-check={false} maxLength={512} name="q" class={cn( "appearance-none focus:outline-none font-light bg-primary block border-b border-solid border-secondary leading-normal py-2 pl-2 pr-8 w-full text-18 placeholder:text-secondary", props.classNames?.input, )} onInput={handleInputChange} value={inputValue()} /> <div class="absolute right-2 top-0 bottom-0 flex space-x-1"> {inputValue() && ( <button type="reset" class="text-16 p-2" aria-label="Reset"> <CloseX focusable="false" aria-label="Reset" className="inline-block text-primary" width="15" height="15" /> </button> )} <button type="submit" class="text-16 p-2" aria-label="Search"> <Search focusable="false" aria-label="Search" className="inline-block text-brand" width="15" height="15" /> </button> </div> </form> );}🤔 Why This Pattern?
- Search-specific input attributes
- Reset functionality
- Visual feedback
- Accessibility features
Form Validation
Section titled “Form Validation”We use structured validation with error reporting:
// Example validation patternconst validateForm = (data: FormData) => { try { const result = schema.safeParse(data);
if (!result.success) { console.error("Validation failed", { data, errors: result.error.errors, component: "FormName", }); return { error: "Please check your input" }; }
return { data: result.data }; } catch (err) { const { airbrakeClient } = await import("#utils/airbrake/browser"); airbrakeClient.notify({ error: err, context: { component: "FormName", severity: "error", }, }); return { error: "An unexpected error occurred" }; }};🤔 Why This Pattern?
- Type-safe validation
- Structured error handling
- Error logging
- User feedback
Decision Checklist
Section titled “Decision Checklist”When implementing forms, ask:
-
Form Structure
- Are fields properly labeled?
- Is validation implemented?
- Are loading states handled?
- Is error handling in place?
-
Accessibility
- Do all fields have labels?
- Are error messages linked to fields?
- Is keyboard navigation supported?
- Are ARIA attributes used correctly?
-
User Experience
- Is feedback immediate?
- Are error messages clear?
- Is the submit button state clear?
- Is form state preserved when needed?
-
Data Handling
- Is data validated before submission?
- Is sensitive data handled securely?
- Are analytics events tracked?
- Is error reporting implemented?