"You've got a Server Action. You need an ID. There are three ways to do it—and two of them are wrong. Here's the right way, and why it matters."
The Problem That Happens on Day One
You're building an edit form. You've got a user, a product, a post—doesn't matter. You need to update it. So you write a Server Action and think: "how do I pass the ID?"
The first instinct? A hidden input.
// ❌ The first instinct
export default function EditForm({ user }: { user: User }) {
return (
<form action={updateUser}>
<input type="hidden" name="id" value={user.id} />
<input name="username" defaultValue={user.name} />
<button type="submit">Update</button>
</form>
)
}
It works. It runs. No errors. So you ship it.
But here's what you just did: you put the ID in the DOM. Anyone can open DevTools, change the value, and submit the form with a different ID. If your Server Action doesn't check permissions properly, you've just handed users a way to edit data that isn't theirs.
That's why hidden inputs are the wrong answer.
The Three Ways to Pass an ID
There are three patterns. One is bad, one is okay, one is the right answer.
Pattern 1: Hidden Input ❌
You already saw this. It works but puts the ID in the DOM. Skip it unless you have a very specific reason—and even then, think twice.
Pattern 2: .bind() ✅
// ✅ Better
export default function EditForm({ user }: { user: User }) {
const updateUserWithId = updateUser.bind(null, user.id)
return (
<form action={updateUserWithId}>
<input name="username" defaultValue={user.name} />
<button type="submit">Update</button>
</form>
)
}
// actions.ts
'use server'
export async function updateUser(id: string, formData: FormData) {
// 👆 comes from .bind()
// 👆 the form data
const username = formData.get('username') as string
await db.users.update({ where: { id }, data: { username } })
}
The ID never touches the DOM. It's bound to the function before the form even renders. Clean.
This is what the Next.js docs recommend. And it's a real improvement over hidden inputs.
But it's still missing things. No loading state. No error handling. No way to show the user what happened. You'd have to wire all of that yourself with useState.
Pattern 3: useActionState + .bind() ✅✅
This is the right answer. React 19's useActionState gives you everything .bind() gives you, plus loading state and action state—built in.
// ✅ The right way
'use client'
import { useActionState } from 'react'
import { updateUser } from './actions'
export default function EditForm({ user }: { user: User }) {
const updateUserWithId = updateUser.bind(null, user.id)
const [state, formAction, isPending] = useActionState(updateUserWithId, null)
return (
<form action={formAction}>
<input name="username" defaultValue={user.name} />
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
{state?.success && (
<p style={{ color: 'green' }}>Updated successfully!</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</button>
</form>
)
}
// actions.ts
'use server'
export async function updateUser(id: string, prevState: unknown, formData: FormData) {
// 👆 .bind() 👆 useActionState 👆 form data
try {
const username = formData.get('username') as string
if (!username) {
return { success: false, error: 'Username is required' }
}
await db.users.update({ where: { id }, data: { username } })
return { success: true, error: null }
} catch {
return { success: false, error: 'Something went wrong' }
}
}
No useState for loading. No useState for errors. No useState for success. It's all there.
The Argument Order: Don't Get This Wrong
This is where most people mess up. When you combine .bind() with useActionState, the argument order in your action is fixed:
(boundArgs..., prevState, formData)
useActionState always injects prevState right before formData. After any bound args.
// With .bind(null, user.id):
export async function updateUser(
id: string, // 1. From .bind()
prevState: unknown, // 2. From useActionState — always here
formData: FormData // 3. The form data — always last
) {}
Flip prevState and id and everything breaks. Silently. No TypeScript error. Just wrong behavior.
Get the order right the first time.
What useActionState Gives You for Free
Let's be specific about why this pattern wins:
isPending — you don't write a single line of loading state logic. The hook handles it. The button disables while the action runs. Done.
state — whatever your action returns becomes state. Return an error? Show it. Return success? Show that. The component re-renders automatically.
Progressive enhancement — the form works without JavaScript. useActionState doesn't break that. If JS loads, you get the enhanced experience. If it doesn't, the form still submits.
No DOM exposure — the ID is bound before render. It never appears in the HTML. Users can't tamper with it via DevTools.
A Real Example: Edit Product Form
Here's a complete, production-ready example:
// app/products/[id]/edit/page.tsx
import { getProductById } from '@/lib/dal/products'
import EditProductForm from '@/app/components/edit-product-form'
export default async function EditProductPage({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProductById(id)
return (
<div>
<h1>Edit Product</h1>
<EditProductForm product={product} />
</div>
)
}
// app/components/edit-product-form.tsx
'use client'
import { useActionState } from 'react'
import { updateProduct } from '@/app/actions'
import type { ProductDTO } from '@/lib/dal/types'
export default function EditProductForm({ product }: { product: ProductDTO }) {
const updateProductWithId = updateProduct.bind(null, product.id)
const [state, formAction, isPending] = useActionState(updateProductWithId, null)
return (
<form action={formAction}>
<div>
<label htmlFor="name">Product Name</label>
<input
id="name"
name="name"
defaultValue={product.name}
required
/>
</div>
<div>
<label htmlFor="price">Price</label>
<input
id="price"
name="price"
type="number"
defaultValue={product.price}
required
/>
</div>
{state?.error && (
<p role="alert" style={{ color: 'red' }}>{state.error}</p>
)}
{state?.success && (
<p role="status" style={{ color: 'green' }}>Product updated!</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</button>
</form>
)
}
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
import { updateProduct } from '@/lib/dal/products'
export async function updateProduct(
id: string,
prevState: unknown,
formData: FormData
) {
try {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
if (!name) return { success: false, error: 'Name is required' }
if (isNaN(price)) return { success: false, error: 'Invalid price' }
await updateProduct(id, { name, price })
revalidateTag('products', 'max')
revalidateTag(`product-${id}`, 'max')
return { success: true, error: null }
} catch {
return { success: false, error: 'Failed to update product' }
}
}
Clean Server Component. Clean Client form. Clean Server Action. Everything in its place.
Which One Should You Use?
Hidden Input |
|
| |
|---|---|---|---|
ID in DOM? | ❌ Exposed | ✅ Hidden | ✅ Hidden |
Loading state | ❌ Manual | ❌ Manual | ✅ Built-in |
The rule is simple:
Need feedback (errors, loading, success)? →
useActionState+.bind()Dead-simple action with zero feedback? →
.bind()alone is fineHidden input? → Don't. Just don't.
In real apps, you almost always need feedback. So useActionState + .bind() wins almost every time.
The Bottom Line
Hidden inputs feel convenient. They're not. They expose data that shouldn't be in the DOM and they leave you wiring up loading and error state manually.
.bind() + useActionState is the pattern React 19 and Next.js were built for. The ID stays off the DOM. The loading state is free. The error handling is built in.
Start with this pattern. Your future self—and your users—will thank you.
Sources:
Happy Coding! — Ahmed Fahmy