DEV Community

Haochen
Haochen

Posted on

1

Interactive React 19 list demo with useOptimistic hook

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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:

context value consumers

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>
)
Enter fullscreen mode Exit fullscreen mode

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);
  };
Enter fullscreen mode Exit fullscreen mode

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" : ""
      }`}
    >
       ...
Enter fullscreen mode Exit fullscreen mode

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

Heroku

Deploy with ease. Manage efficiently. Scale faster.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)