Dec 8th, 2021: [en] Optimistic UI in Kibana with React Query

This article is also available in pt-br

"Sometimes the use of the Optimistic UI approach is what you need to enhance the experience for our users."

Overview

If you are familiar with React Query, you know it is easy to use and gives developers total control of fetching, cache, and the data retrieved from the server. Also, you can easily implement Optimistic UI with React Query.

Optimistic UI is a well-known technique that makes the UI shows to the users immediate feedback of the actions they just executed because it considers the action will succeed, essentially they are related to mutations, that is when users are changing (mutating) server data or performing server-side effects. Examples of well-implemented Optimistic UI are chat apps, when users click to send a message, it is immediately shown in the chat UI, even though it is usually not yet sent the request to the server.

Let's use a TODO list as an example because it covers all the necessary examples like fetching and mutations (create/update/delete).

The traditional way

Typically, when the user clicks on the Add Todo button, it would trigger an API call to the server. Once the server responds, the Frontend checks the response type, and if it is a success, it updates the UI accordingly. If it fails, it shows to the user an Error notification. While this approach ensures we always have a UI consistent with the server, it makes the user wait for the request to finish, fetch the todo list again, and then finally show the todo created in the list.

Article-Todo-1
Example of a traditional add todo action

The code is simple and straightforward:

// Add a todo
const { mutate: createMutate } = useMutation(postAddTodo, {
  onError: (error: any, variables) => {
   // Show error notification 
    notifications.toasts.addDanger(
      `Error: ${error.message} when attempting to insert ${variables.description}`
    );
  },
  onSuccess: (res: any) => {
    // Refetch the todo list:
    queryClient.invalidateQueries('todos')
  },
});

It is not a bad experience and usually when the response is fast and the re-rendering of the list is cheap, it's no problem at all from the user’s perspective, but with Optimistic UI, the experience can be enhanced.

The optimistic way

With Optimistic UI, users will be able to see the final state of the UI before the operation is finished on the server, leading to a more fluid experience for the user. In the following example, we want to update the state of the to-do list immediately and not wait for the server response.

Article-Todo-Optimistic-1
Example of adding todo with Optimistic UI

And the code according with React Query doc is like this:

const { mutate: createMutate } = useMutation(postAddTodo, {
  // When mutate is called:
  onMutate: async (todo) => {
    // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries('todos');

    // Snapshot the previous value
    const previousTodos = queryClient.getQueryData('todos');

    // Build a new todo object, only the necessary fields to show in the UI
    const newTodo: Todo = {
      _id: todo.id,
      _source: {
        description: todo.description,
        completed: false
      },
    };

    // Optimistically adding the new todo into the current todo list
    queryClient.setQueryData('todos', (previousData: any) => ({
      ...previousData,
      hits: [...previousData.hits, newTodo],
      total: {
        ...previousData.total,
        value: previousData.total.value + 1, // Incrementing the total count
      },
    }));

    // Return a context object with the snapshotted value
    return { previousTodos };
  },
  onError: (error: any, variables, context: any) => {
    // If the mutation fails, use the context returned from onMutate to roll back
    queryClient.setQueryData('todos', context.previousTodos);

    notifications.toasts.addDanger(
      `Error: ${error.message} when attempting to insert ${variables.description}`
    );
  },
  // Always refetch after error or success:
  onSettled: () => {
    queryClient.invalidateQueries('todos');
  }
});

As we see in the code, if the request fails, the Optimistic action needs to be reverted, to avoid having the UI out of sync with the server (non-consistent state), so, in case of success or failure it is syncing with the server, so the UI always remains consistent.

Article-Todo-Optimistic-Error-1
Error example, for demo purposes, the server was configured to return 400 when the description equals to "force error"

Caveats

However, there are certain caveats to be aware of, for example, what happens if users try to mutate the todo before it is created on the server? A simple workaround would be to provide a placeholder flag and prevent actions before the UI is in sync with the server. Also, in some cases, always refetching can still lead to a poor experience, especially if the list is large, or has other components that the user can be interacting with at the time the list is being re-mounted, or in some cases, it's just more useful for the user perspective, to keep the inserted item in an error state, so the user can try to edit and submit again.

This all can be handled with code:

Article-Todo-Optimistic-Error-2

As we can see, the experience is now more fluid for the user, but that comes at the cost of adding more complexity into the app:

// Add a todo
  const { mutate: createMutate } = useMutation(postAddTodo, {
    // When mutate is called:
    onMutate: async (todo) => {
      // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries('todos');

      // Build a new todo object, only the necessary fields to show in the UI
      const newTodo: Todo = {
        _id: todo.id,
        _source: {
          description: todo.description,
          completed: false,
          placeholder: true, // This is a placeholder flag, we are using to prevent mutations while not synced with the server
          timestamp: new Date().toISOString(),
        },
      };

      // Optimistically adding the new todo into the current todo list
      queryClient.setQueryData('todos', (previousData: any) => ({
        ...previousData,
        hits: [...previousData.hits, newTodo],
        total: {
          ...previousData.total,
          value: previousData.total.value + 1, // Incrementing the total count
        },
      }));
    },
    onError: (error: any, variables) => {
      // Reverting the optimistic insert, removing the todo from the current todo list
      queryClient.setQueryData('todos', (previousData: any) => ({
        ...previousData,
        hits: previousData.hits.map((todo: any) => {
          if (todo._id === variables.id) {
            return {
              ...todo,
              _source: {
                ...todo._source,
                error: true, // Adding error flag, so the UI can show the todo in an error state
              },
            };
          }
          return todo;
        }),
        total: {
          ...previousData.total,
          value: previousData.total.value - 1, // Decrementing the total count
        },
      }));

      notifications.toasts.addDanger(
        `Error: ${error.message} when attempting to insert ${variables.description}`
      );
    },
    onSuccess: (res: any) => {
      // Optimistically sync the inserted todo with the server
      queryClient.setQueryData('todos', (previousData: any) => ({
        ...previousData,
        hits: previousData.hits.map((todo: any) => {
          // Change only the updated todo from the list with information from the server
          if (todo._id === res.todo.id) {
            return {
              ...todo,
              _source: {
                ...res.todo.body,
                placeholder: false, // Removing the placeholder flag, so the users can interact with the todo
              },
            };
          }
          return todo;
        }),
      }));
    },
  });

Conclusion

Optimistic UI is a great technique that can be used to enhance the experience for your users, it makes the app sounds faster because there is no loading and the user is not blocked while waiting for the requests to finish. However, it will not always be the best solution, there are cases where we even shouldn't be using it because it's fundamental to wait for the request to finish in order to continue (i.e payments), but consider Optimistic UI as one extra tool in your toolbelt.

Code Repository

The todo list code is available in Github, as a plugin for Kibana, todos are stored in Elasticsearch indices, with more examples of creating/deleting and marking todo as completed.

References:

2 Likes

This topic was automatically closed 28 days after the last reply. New replies are no longer allowed.