Try it out here: https://interactive-nextjs-list-demo-blond.vercel.app/
The syntax of useOptimistic
is similar to a useReducer
, const [optimisticState, addOptimistic] = useOptimistic(state, updateFn)
, where updateFn
can be a reducer function.
This is the reducer function for this example:
export type OptimisticItem = Item & {
updating?: boolean;
deleting?: boolean;
creating?: boolean;
};
export type ItemReducerAction =
| { type: "update"; item: ItemUpdateInput }
| { type: "delete"; itemId: string }
| { type: "create"; item: Item };
export const itemReducer = (
state: OptimisticItem[],
action: ItemReducerAction,
) => {
switch (action.type) {
case "update":
return state.map((item) =>
item.id === action.item.id
? { ...item, ...action.item, updating: true }
: item,
);
case "delete":
return state.map((item) =>
item.id === action.itemId ? { ...item, deleting: true } : item,
);
case "create":
return [{ ...action.item, creating: true }, ...state];
default:
return state;
}
};
Noticed there are extra states updating, deleting, creating
added to the item by the reducer, which will be handy to display visual cues to the user later.
I opted to use useOptimistic
in combination with the context api:
export const OptimisticItemsContext = createContext<{
items: OptimisticItem[];
updateOptimisticItems: (action: ItemReducerAction) => void;
} | null>(null);
export const ItemsProvider = ({
children,
items,
}: {
children: React.ReactNode;
items: Item[];
}) => {
const [optimisticItems, updateItems] = useOptimistic<
OptimisticItem[],
ItemReducerAction
>(items, itemReducer);
const [, startTransition] = useTransition();
const updateOptimisticItems = (action: ItemReducerAction) => {
startTransition(() => {
updateItems(action);
});
};
return (
<OptimisticItemsContext.Provider
value={{
items: optimisticItems,
updateOptimisticItems,
}}
>
{children}
</OptimisticItemsContext.Provider>
);
};
export const useOptimisticItemsContext = () => {
const context = useContext(OptimisticItemsContext);
if (!context) {
throw new Error(
"useOptimisticItemsContext must be used within a OptimisticItemsProvider",
);
}
return context;
};
I think one key takeaway of useOptimistic
is that it only creates a temporary state that live between the mutation's started and settled period, as the dispatch
function of useOptimistic
can only be called in a form action or in a transition. Notice I wrap the dispatch
function with a startTransition
.
The context value is mainly consumed by the ListTable and AddItemForm component:
In their parent component, the items data is fetched by a server function and passed to the ItemsProvider as prop:
const { items, totalPages, total } = await getItems()
...
return (
<ItemsProvider items={items}>
...
</ItemsProvider>
)
So instead of the original items
data, the ItemTable will display the optimistic version of the items.
Take creating an item as example, I update the state by calling updateOptimisticItems
before making the real mutation request:
const addItemAction = (formData: FormData) => {
formRef.current?.reset();
updateOptimisticItems({
type: "create",
item: {
id: new Date().getTime().toString(),
userId: "xxx",
name: formData.get("name") as string,
pieces: parseInt(formData.get("pieces") as string),
...
},
});
mutate(formData);
};
If you try to add an item, you will see the temporary placeholder item appear on top of the ItemTable instantly. After the mutation is succeeded, React replace the placeholder item with the real new item fetched by revalidatePath("/")
seamlessly. At this point, I wonder if the extra "refetch" is necessary. If you have any thoughts about this, please let me know.
I was used to working with react-query's caching to improve UX, where I wait for mutation to settle and meanwhile display a loading state, after mutation succeeded, modify cached data without making another fetch, aka. app-like experience. But useOptimistic
go beyond that, it can provide instant state change right after the mutation is triggered and before it's settled. It is like what they do in real-time action games, register user input in local device and send request at the same time.
In the ItemForm component, I use the extra state attached to the modified item to display css animations on the items:
const isEditing =
(!item.updating && editingId === item.id) ||
Object.keys(validationErrors).length !== 0;
const isNewlyCreated = item.creating;
const isNewlyUpdated =
new Date().getTime() - new Date(item.updatedAt).getTime() < 1000 * 10;
const isNewlyDeleted = item.deleting;
return (
<div
key={item.id}
className={`p-4 md:p-8 border rounded-lg transition-colors ${
isNewlyCreated ? "created-animation" : ""
} ${isNewlyUpdated ? "confirmed-animation" : ""} ${
isNewlyDeleted ? "deleting-animation" : ""
}`}
>
...
One more thing, it is surprisingly easy to handle error and "roll back" state. The way useOptimisic
works give this to us for free, since mentioned earlier, the state only lives between the mutation is triggered and settled. So we can handle mutation error like we used to.
I only use useOptimistic
to manage the items' state, the categories ' state are managed by Next.js's default way of doing. I'm still exploring and learning about Next.js 15 and React 19, there can be things less ideal in this demo, I'm open to any thoughts and learn from you.
Feel free to fork or clone the repo here: https://github.com/hcsum/interactive-nextjs-list-demo
Top comments (0)