The Action Prop Pattern

Design components should handle their own in-between states internally—optimistic updates, pending indicators, transitions—rather than pushing that complexity to every consumer. The pattern: pass the action to perform, let the component handle the coordination.

This creates reusable components that work consistently across your app. Parents don't need to know about transitions or optimistic state—they just pass what should happen.

The TabList Example

A tablist that handles its own optimistic updates and transitions:

'use client'; import { useOptimistic, useTransition } from 'react'; type Props = { tabs: { value: string; label: string }[]; activeTab: string; changeAction?: (value: string) => void | Promise<void>; }; export function TabList({ tabs, activeTab, changeAction }: Props) { const [optimisticTab, setOptimisticTab] = useOptimistic(activeTab); const [isPending, startTransition] = useTransition(); function handleTabChange(value: string) { startTransition(async () => { setOptimisticTab(value); await changeAction?.(value); }); } return ( <Tabs value={optimisticTab} onValueChange={handleTabChange}> <TabsList> {tabs.map(tab => ( <TabsTrigger key={tab.value} value={tab.value}> {tab.label} </TabsTrigger> ))} </TabsList> {isPending && <Spinner />} </Tabs> ); }

The component owns:

  • Optimistic tab switching (instant visual feedback)
  • Transition wrapping (keeps content visible during fetch)
  • Pending indicator (spinner next to tabs)

Simple Parent Code

Parents become simple—just pass the action:

'use client'; import { useRouter } from 'next/navigation'; export function PostTabs({ currentFilter, currentSort }) { const router = useRouter(); return ( <TabList tabs={[ { value: 'all', label: 'All' }, { value: 'published', label: 'Published' }, { value: 'drafts', label: 'Drafts' }, ]} activeTab={currentFilter} changeAction={value => router.push(`/dashboard?filter=${value}&sort=${currentSort}`)} /> ); }

The parent doesn't import useTransition, doesn't manage optimistic state, doesn't show a spinner—that's all encapsulated.

Naming Convention

Suffix action props with "Action" to distinguish them from regular callbacks:

  • changeAction — for state changes
  • submitAction — for form submissions
  • deleteAction — for deletions

This signals that the function may be async and will be wrapped in a transition.

The Principle

Design components own their in-between states. Parents pass what should happen; components handle how it looks while happening.

October 3, 2025Updated February 13, 2026332 words