Optimistic Updates: Comparing State-Based and Render-Based Approaches

Happy server arranging cute data updates

Optimistic Updates: Comparing State-Based and Render-Based Approaches

Optimistic updates are a powerful technique for enhancing the user experience in web applications by preemptively reflecting changes on the UI, assuming the backend request will succeed. However, there are different ways to implement optimistic updates depending on the frontend framework or library you’re using. The two primary approaches are state-based and render-based. Let’s break down how these approaches work, their trade-offs, and when each one shines.

State-Based Approach

State-Based Approach Chart

In the state-based approach, you manage changes by using a state variable that holds the optimistic state. For example, when adding a new item to a list, you immediately update the state variable to reflect the new item, even before the server has confirmed the change. You assume the request will succeed and show the updated list right away. If the server later returns an error or fails, you update the state variable to revert the changes and restore the previous state. This gives you control over how updates are handled, but it requires managing both the optimistic state and the possibility of an error that requires a rollback.

This approach works well in scenarios where you want to control the exact mutation logic yourself. It gives you full flexibility to handle various edge cases but introduces complexity because you need to manage both the optimistic state and the possibility of a rollback.

Introducing useOptimistic

In the state-based approach, you create a new optimistic state using useOptimistic. This function takes an initial state and a reducer-like function to handle updates. You explicitly call the update function to optimistically update the state with the desired value, assuming the server request will succeed. If the server request fails, useOptimistic automatically reverts the state to its previous value by hooking into React’s transition mechanism, so you don’t need to handle the rollback yourself.

Example (React with useOptimistic):

"use client";

import { useOptimistic } from "react";
import { send } from "./actions";

type Message = {
  id: string;
  message: string;
};

export function Thread({ messages }: { messages: Array<Message> }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<
    Array<Message & { isOptimistic?: boolean }>,
    string
  >(messages, (state, newMessage) => [
    ...state,
    { id: `${Date.now()}`, message: newMessage, isOptimistic: true },
  ]);

  function formAction(formData: FormData) {
    const message = formData.get("message") as string;
    addOptimisticMessage(message); // Apply optimistic update
    void send(message); // Server request
  }

  return (
    <div>
      <ul>
        {optimisticMessages.map((m, i) => {
          return (
            <li
              key={m.id}
              className={m.isOptimistic ? "opacity-50" : undefined}
            >
              {m.message}
            </li>
          );
        })}
      </ul>
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

When it shines:

The state-based approach excels in situations where you’re managing interactive UI elements like drag-and-drop operations or reordering lists. In these cases, you can maintain a clear, predictable state that updates the UI immediately, and if something goes wrong, you can revert it back to its original state. It is particularly useful when you need to manage elements in an array, or when you want to show dynamic changes without waiting for the backend response.

Trade-offs:

  • Complexity: You need to maintain additional state and handle updates manually, which increases the complexity of your code.
  • Missing data: If the backend generates data (e.g., an ID for a new item), you’ll have to create placeholders or find creative ways to handle these cases and avoid rendering inconsistencies. For instance, in a previous job, we had to manually patch the React Query cache (before mutation variables were available), which made our update logic overly complex. Even simple optimistic updates had ripple effects, impacting unrelated parts of the UI that relied on the same state. This added significant maintenance overhead.
  • Potential UI inconsistency: Problems can arise when using values like IDs or timestamps that are unpredictable from the server. For example, if you optimistically add an item to a list and generate an ID yourself, but the server returns a different ID later, this mismatch can cause inconsistencies between the optimistic state and the final server response, making these issues difficult to debug.
  • Increased type complexity: Handling optimistic updates may require defining new types or modifying existing ones. Because some fields might exist in the optimistic state but not yet be confirmed by the server, you must accommodate both versions in your codebase, adding extra complexity.

Render-Based Approach

Render-Based Approach Chart

In contrast to the state-based approach, the render-based approach keeps the UI logic simpler by directly using the mutation variables within the rendering process. Once a mutation is fired, the UI optimistically reflects the expected change based on the mutation’s variables. This means you don’t need to manage any additional state outside the mutation itself. If the mutation fails, the UI automatically reverts to the previous state without any explicit rollback mechanism because the change is tied to the mutation’s state.

With this approach, the optimistic update logic is part of the rendering flow rather than managed in separate state variables, which simplifies the overall implementation. You rely on the mutation’s pending variables to show the optimistic update, and if the mutation fails, it naturally reverts without any manual intervention.

Example (React with Tanstack Query):

"use client";

import { useMutation } from "@tanstack/react-query";
import { send } from "./actions";

type Message = {
  id: string;
  message: string;
};

export function Thread({ messages }: { messages: Array<Message> }) {
  const sendMessageMutation = useMutation({
    mutationFn: send,
  });

  function formAction(formData: FormData) {
    const message = formData.get("message") as string;
    sendMessageMutation.mutate(message);
  }

  return (
    <div>
      <ul>
        {messages.map((m) => {
          return <li key={m.id}>{m.message}</li>;
        })}
        {sendMessageMutation.variables != null ? (
          <li className="opacity-50">{sendMessageMutation.variables}</li>
        ) : null}
      </ul>
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

When it shines:

The render-based approach shines in cases where you’re performing simple updates, such as adding or deleting items from a list, or displaying new content based on a mutation. It works particularly well when you don’t need to track additional state variables and the mutation state alone can handle the rendering. By leveraging the mutation’s variables, the UI immediately reflects the expected change, and it reverts gracefully if the mutation fails.

This approach is also ideal in scenarios where you want to display multiple mutations concurrently. In Solid.js, for example, the useSubmissions hook allows you to handle multiple mutation variables at once, making it easy to perform multiple actions (like adding several todos) without blocking the UI while waiting for each server request to complete.

Trade-offs:

  • Less flexibility: Although it’s simpler, the render-based approach might not be suitable for more complex UI interactions. For example, when you need precise control over state transitions or want to manually handle specific rollback scenarios, the state-based approach offers more flexibility.
  • Limited to mutation scope: The rendering logic is tightly coupled with the mutation itself. If you need more advanced state handling (e.g., for interactions like drag-and-drop reordering), you may need to transition to a state-based approach.

Conclusion: Choosing Between State-Based and Render-Based Approaches

Both approaches have their strengths, and the best choice depends on your specific use case:

  • State-based approach: This approach is more suited for scenarios where the optimistic update logic itself is complex, such as reordering items in a list or handling drag-and-drop interactions. It provides the flexibility needed to manage these intricate state transitions, but it adds complexity since you have to carefully handle the optimistic updates and ensure a consistent UI state.

  • Render-based approach: I personally favor and recommend this approach for its simplicity and efficiency, especially for straightforward updates like adding items to the end of a list. It eliminates the need for managing extra state variables and is highly effective in scenarios where multiple mutations occur concurrently, keeping the UI responsive and the code clean.

Ultimately, the choice comes down to the complexity of the UI and how much control you need over the mutation flow. If you need to handle more complex states or transitions, the state-based approach is the way to go. However, for most straightforward scenarios, the render-based approach will keep your code clean and efficient.