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 changessubmitAction— for form submissionsdeleteAction— 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.