Calling Server Actions from Event Handlers and `useTransition`

How to call a Server Action from onClick or useEffect, wrapped in useTransition, and why the pending flag depends on it.

7 min read

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.tsxApp.tsx
// 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.

App.tsxApp.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.tsxApp.tsx
// 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.

TriggerTransitionArgument
Form action propAutomaticFormData
Event handler or effectYou wrap it in startTransitionWhatever 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why does my isPending flag never turn true?

The dispatch must run inside a transition. Pass it to a form action prop, or wrap the call in startTransition. Outside a transition, React does not track the pending state.

Do I need startTransition after await?

Yes. State updates after an await inside startTransition are not marked as transitions, so wrap them in another startTransition call.

Can I call a Server Action from useEffect?

Yes. Wrap the call in startTransition inside the effect, the same way you would from a click handler.

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.