Skip to content

Forms and User Input Patterns

This guide documents our established patterns for handling forms and user input, using real examples from our codebase.

We have a reusable base input component with consistent styling and behavior:

components/fields/input.tsx
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

We use Astro actions for form handling, which provides built-in type safety, validation, and error handling. There are two main approaches:

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 validation
export const newsletterAction = defineAction({
name: "newsletter",
schema: z.object({
email: z.string().email("Please enter a valid email"),
}),
});
// Get action result to show feedback
const 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

Use this approach when you need more interactive form handling:

src/actions/newsletter.ts
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.tsx
import { 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
  1. Action Definition

    • Define schemas for validation
    • Provide meaningful error messages
    • Handle all error cases
    • Return consistent response shapes
  2. Form Implementation

    • Use HTML forms for simple cases
    • Use SolidJS for complex forms
    • Show loading states
    • Provide clear feedback
  3. Error Handling

    • Validate input server-side
    • Return structured errors
    • Show user-friendly messages
    • Log errors appropriately
  4. Security

    • Validate all inputs
    • Sanitize data
    • Rate limit submissions
    • Prevent CSRF attacks

Don’t: Skip server-side validation

// Bad: Client-only validation
const handler = ({ email }) => {
// No validation before use
return subscribeUser(email);
};

Do: Always validate server-side

// Good: Server-side validation
const 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 implementation
fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
});

Do: Use Astro actions

// Good: Type-safe action calls
const result = await actions.newsletter({
email: email(),
});

When implementing forms, ask:

  1. Form Complexity

    • Is JavaScript required?
    • Are there complex interactions?
    • Is real-time validation needed?
  2. Data Handling

    • Is input validation defined?
    • Are all error cases handled?
    • Is feedback provided?
  3. User Experience

    • Are loading states shown?
    • Is error feedback clear?
    • Is success feedback shown?
  4. Security

    • Is data validated?
    • Are inputs sanitized?
    • Is rate limiting implemented?

Here’s our pattern for search inputs:

components/typesense/searchBox.tsx
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

We use structured validation with error reporting:

// Example validation pattern
const 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

When implementing forms, ask:

  1. Form Structure

    • Are fields properly labeled?
    • Is validation implemented?
    • Are loading states handled?
    • Is error handling in place?
  2. Accessibility

    • Do all fields have labels?
    • Are error messages linked to fields?
    • Is keyboard navigation supported?
    • Are ARIA attributes used correctly?
  3. User Experience

    • Is feedback immediate?
    • Are error messages clear?
    • Is the submit button state clear?
    • Is form state preserved when needed?
  4. Data Handling

    • Is data validated before submission?
    • Is sensitive data handled securely?
    • Are analytics events tracked?
    • Is error reporting implemented?