Calling Server Actions from event handlers works the same way it does from forms, with one addition: you wrap the call in startTransition so React can track the pending state.
useTransition is the hook that gives you both the startTransition function and an isPending flag. It is the same mechanism a form action prop uses internally, which is why forms get a pending state for free and click handlers do not.
Reach for this when a button acts on a single known target and a full form would be overhead.
// app/like-button.tsx
'use client'
import { useTransition } from 'react'
import { incrementLike } from './actions'
export function LikeButton({ likes }: { likes: number }) {
const [isPending, startTransition] = useTransition()
const onClick = () => startTransition(() => incrementLike())
return <button disabled={isPending} onClick={onClick}>Like ({likes})</button>
}The click runs incrementLike on the server. While that call is in flight isPending is true, so the button renders disabled and a second click cannot fire. When the action settles the button becomes clickable again.
This component needs use client because hooks run in the browser. The count itself still comes from the server through the likes prop, so it updates when the page revalidates.
Why startTransition matters
React only tracks pending state when the action runs inside a transition.
Call a Server Action directly from onClick and the request still fires and still succeeds. What you lose is the feedback: React never marks anything as pending, so isPending stays false and the button gives no sign that work is happening. Nothing errors, which is what makes this one easy to miss.
The rule is simple: pass the action to a form action prop and React wraps it for you, or wrap the call in startTransition yourself when using a plain event handler.
There is no third option. isPending comes from the transition, so if nothing opened one, there is no pending state for React to report.
The after-await rule
There is one catch. State updates that happen after an await are not marked as transitions, because React loses the async context across the await. React documents this as a known limitation, not a bug, and the fix is to open a second transition.
Here is the same button storing the value the action returns, in a file such as app/like-counter.tsx.
'use client'
import { useState, useTransition } from 'react'
import { incrementLike } from './actions'
export function LikeCounter() {
const [count, setCount] = useState(0)
const [isPending, startTransition] = useTransition()
const onClick = () => startTransition(async () => {
const next = await incrementLike()
startTransition(() => setCount(next))
})
return <button disabled={isPending} onClick={onClick}>Like ({count})</button>
}The inner startTransition around setCount is the part people leave out. Without it the count still updates on screen, but the update is not a transition, so isPending can clear before React has committed the new value and the button flickers back early.
Calling from useEffect
The same pattern applies inside useEffect, for work that should run when the component mounts or a dependency changes. Wrap the action call in startTransition and store any returned value in state.
This is the pattern for view counters and other mutations triggered by the page rather than by the visitor. A view counter increments on mount and stores the returned number, and the transition keeps that update from blocking the first paint.
Two things matter here. Give the effect a dependency array so it does not fire on every render, and remember that React runs effects twice in development Strict Mode, so an unguarded counter increments twice locally.
If the mutation must not repeat, key it on something stable such as the record id, or move it to the server where the request itself is the trigger.
Passing arguments without a form
A form passes FormData for free. An event handler passes whatever arguments you write, which makes it good for mutations that do not need a form, such as marking an item read.
// app/item-row.tsx
'use client'
import { useTransition } from 'react'
import { markRead } from './actions'
export function ItemRow({ id, read }: { id: number; read: boolean }) {
const [isPending, startTransition] = useTransition()
return <button disabled={isPending || read} onClick={() => startTransition(() => markRead(id))}>
{read ? 'Read' : 'Mark read'}
</button>
}The button disables itself while the action runs, and markRead receives the id directly instead of a FormData object. No form markup is needed for a single-row mutation.
The same call shape works for any action that takes a plain argument, such as deleting by id or pinning by slug. Note that the argument still crosses the network, so it must be serializable, and the action still has to confirm the caller is allowed to touch that id.
Event handlers vs forms
A form action prop already runs inside a transition, which is why form examples never mention startTransition. Event handlers are the case where you add it yourself, and the same rule covers keybindings, scroll triggers, and any other callback that is not a form.
| Trigger | Transition | Argument |
|---|---|---|
| Form action prop | Automatic | FormData |
| Event handler or effect | You wrap it in startTransition | Whatever you pass |
The choice follows the input. If the visitor is filling out fields, use a form. If they are clicking a button with a known target, an event handler is cleaner and skips the form markup entirely.
For a form that also needs the server's return value and error handling, see useActionState for pending state and server errors. For instant UI before the server answers, see optimistic UI with useOptimistic. The directive that makes these functions server-only is covered in the use server directive.
Rune AI
Key Insights
- Event handlers call Server Actions through startTransition.
- useTransition returns isPending and startTransition.
- Without startTransition, isPending does not update.
- Wrap state updates after await in another startTransition.
- Form actions already run inside a transition automatically.
Frequently Asked Questions
Why does my isPending flag never turn true?
Do I need startTransition after await?
Can I call a Server Action from useEffect?
Conclusion
To call a Server Action outside a form, wrap the call in startTransition from useTransition. That gives you an isPending flag for the button, and you must wrap any state update after an await in another startTransition.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.