Forms & Actions
Forms in @putnami/react use progressive enhancement: they work without JavaScript and become richer with it. Actions handle form submissions on the server.
Basic form
Action handler
// src/app/contact/action.ts
import { action } from '@putnami/react';
export default action(async (ctx) => {
const data = await ctx.body<{
name: string;
email: string;
message: string;
}>();
await sendContactEmail(data);
return { ok: true, message: 'Message sent!' };
});Form component
// src/app/contact/page.tsx
import { Form, useActionData, useNavigation } from '@putnami/react';
export default function ContactPage() {
const actionData = useActionData<{ ok: boolean; message?: string; error?: string }>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
{actionData?.ok && <p className="success">{actionData.message}</p>}
{actionData?.error && <p className="error">{actionData.error}</p>}
</Form>
);
}Form validation
Server-side validation
// src/app/register/action.ts
import { action } from '@putnami/react';
import { validateSync, IsEmail, MinLength, IsString } from '@putnami/runtime';
class RegisterDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
@MinLength(8)
password: string;
}
export default action(async (ctx) => {
const body = await ctx.body<RegisterDto>();
const dto = Object.assign(new RegisterDto(), body);
const errors = validateSync(dto);
if (errors.length > 0) {
return {
ok: false,
errors: errors.reduce((acc, err) => {
acc[err.property] = Object.values(err.constraints || {});
return acc;
}, {} as Record<string, string[]>),
};
}
// Check if email exists
const existing = await findUserByEmail(body.email);
if (existing) {
return {
ok: false,
errors: { email: ['Email already registered'] },
};
}
const user = await createUser(body);
return { ok: true, userId: user.id };
});Displaying validation errors
// src/app/register/page.tsx
import { Form, useActionData, useNavigation } from '@putnami/react';
interface ActionResult {
ok: boolean;
errors?: Record<string, string[]>;
userId?: string;
}
export default function RegisterPage() {
const actionData = useActionData<ActionResult>();
const navigation = useNavigation();
const errors = actionData?.errors || {};
return (
<Form method="post">
<div>
<input name="name" placeholder="Name" />
{errors.name?.map((err, i) => (
<span key={i} className="error">{err}</span>
))}
</div>
<div>
<input name="email" type="email" placeholder="Email" />
{errors.email?.map((err, i) => (
<span key={i} className="error">{err}</span>
))}
</div>
<div>
<input name="password" type="password" placeholder="Password" />
{errors.password?.map((err, i) => (
<span key={i} className="error">{err}</span>
))}
</div>
<button type="submit" disabled={navigation.state === 'submitting'}>
Register
</button>
</Form>
);
}Submission states
Navigation state
import { useNavigation } from '@putnami/react';
function SubmitButton() {
const navigation = useNavigation();
// navigation.state can be:
// - 'idle': No pending navigation
// - 'submitting': Form is being submitted
// - 'loading': Navigation is loading
const isSubmitting = navigation.state === 'submitting';
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner /> Submitting...
</>
) : (
'Submit'
)}
</button>
);
}Optimistic UI
import { Form, useNavigation, useActionData } from '@putnami/react';
function TodoItem({ todo }: { todo: Todo }) {
const navigation = useNavigation();
// Check if this specific form is submitting
const isDeleting =
navigation.state === 'submitting' &&
navigation.formData?.get('todoId') === todo.id;
return (
<div style={{ opacity: isDeleting ? 0.5 : 1 }}>
<span>{todo.title}</span>
<Form method="post" action="/todos/delete">
<input type="hidden" name="todoId" value={todo.id} />
<button type="submit" disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</Form>
</div>
);
}Fetchers
Use useFetcher for form submissions that don't navigate.
Basic fetcher
import { useFetcher } from '@putnami/react';
function NewsletterSignup() {
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
return (
<fetcher.Form method="post" action="/api/newsletter">
<input name="email" type="email" placeholder="Email" />
<button type="submit" disabled={fetcher.state === 'submitting'}>
{fetcher.state === 'submitting' ? 'Subscribing...' : 'Subscribe'}
</button>
{fetcher.data?.ok && <p>Thanks for subscribing!</p>}
{fetcher.data?.error && <p className="error">{fetcher.data.error}</p>}
</fetcher.Form>
);
}Fetcher for data loading
import { useFetcher } from '@putnami/react';
import { useEffect } from 'react';
function UserSearch() {
const fetcher = useFetcher<{ users: User[] }>();
const [query, setQuery] = useState('');
useEffect(() => {
if (query.length > 2) {
fetcher.load(`/api/users/search?q=${encodeURIComponent(query)}`);
}
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search users..."
/>
{fetcher.state === 'loading' && <p>Searching...</p>}
{fetcher.data?.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}Multiple fetchers
import { useFetcher } from '@putnami/react';
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const fetcher = useFetcher();
const isToggling = fetcher.state === 'submitting';
const optimisticComplete = isToggling
? fetcher.formData?.get('completed') === 'true'
: todo.completed;
return (
<li style={{ opacity: isToggling ? 0.7 : 1 }}>
<fetcher.Form method="post" action="/api/todos/toggle">
<input type="hidden" name="id" value={todo.id} />
<input type="hidden" name="completed" value={String(!todo.completed)} />
<button type="submit">
{optimisticComplete ? '✓' : '○'}
</button>
</fetcher.Form>
<span>{todo.title}</span>
</li>
);
}File uploads
Form with file input
import { Form, useActionData, useNavigation } from '@putnami/react';
export default function UploadPage() {
const actionData = useActionData<{ ok: boolean; url?: string }>();
const navigation = useNavigation();
return (
<Form method="post" encType="multipart/form-data">
<input type="file" name="file" accept="image/*" />
<button type="submit" disabled={navigation.state === 'submitting'}>
Upload
</button>
{actionData?.ok && (
<img src={actionData.url} alt="Uploaded" />
)}
</Form>
);
}File upload action
// src/app/upload/action.ts
import { action } from '@putnami/react';
export default action(async (ctx) => {
const formData = await ctx.req.formData();
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return { ok: false, error: 'No file provided' };
}
// Validate file type
if (!file.type.startsWith('image/')) {
return { ok: false, error: 'Only images allowed' };
}
// Save file
const buffer = await file.arrayBuffer();
const path = `/uploads/${crypto.randomUUID()}-${file.name}`;
await Bun.write(`public${path}`, buffer);
return { ok: true, url: path };
});Programmatic submission
useSubmit
import { useSubmit } from '@putnami/react';
function AutoSaveForm() {
const submit = useSubmit();
const formRef = useRef<HTMLFormElement>(null);
// Auto-save on change
const handleChange = () => {
if (formRef.current) {
submit(formRef.current, { replace: true });
}
};
return (
<form ref={formRef} method="post" onChange={handleChange}>
<input name="title" />
<textarea name="content" />
</form>
);
}Submit with custom data
import { useSubmit } from '@putnami/react';
function DeleteButton({ id }: { id: string }) {
const submit = useSubmit();
const handleDelete = () => {
if (confirm('Are you sure?')) {
submit(
{ id, action: 'delete' },
{ method: 'post', action: '/api/items' }
);
}
};
return <button onClick={handleDelete}>Delete</button>;
}Action patterns
Redirect after action
// action.ts
import { action } from '@putnami/react';
import { HttpResponse } from '@putnami/application';
export default action(async (ctx) => {
const body = await ctx.body<CreatePostInput>();
const post = await createPost(body);
// Redirect to the new post
return HttpResponse.redirect(`/posts/${post.id}`);
});Return errors vs throw
import { action } from '@putnami/react';
// Returning errors (for validation)
export default action(async (ctx) => {
const body = await ctx.body<Input>();
if (!body.email) {
return { ok: false, error: 'Email required' };
}
return { ok: true };
});
// Throwing errors (for unexpected failures)
export default action(async (ctx) => {
const body = await ctx.body<Input>();
const user = await findUser(body.userId);
if (!user) {
throw new NotFoundException('User not found');
}
return { ok: true };
});Multiple actions in one route
// action.ts
import { action } from '@putnami/react';
export default action(async (ctx) => {
const body = await ctx.body<{ intent: string; [key: string]: unknown }>();
switch (body.intent) {
case 'create':
return handleCreate(body);
case 'update':
return handleUpdate(body);
case 'delete':
return handleDelete(body);
default:
return { ok: false, error: 'Unknown intent' };
}
});// page.tsx
<Form method="post">
<input type="hidden" name="intent" value="create" />
{/* ... */}
</Form>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={item.id} />
<button type="submit">Delete</button>
</Form>CSRF protection
When the server enables CSRF protection via http({ csrf: true }), form actions and the useFetch hook automatically include the CSRF token in the X-CSRF-Token header. No client-side code changes are required.
<Form>POST submissions — token is read from the_csrfcookie and attached automatically.useFetchwithPOST,PUT,DELETE,PATCH— token is injected automatically.GET,HEAD, andOPTIONSare skipped.
For manual fetch() calls, read the token from document.cookie:
const csrfToken = document.cookie
.split('; ')
.find(c => c.startsWith('_csrf='))
?.split('=')[1];
fetch('/api/submit', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: JSON.stringify(data),
});See HTTP & Middleware — CSRF protection for server configuration options.