Dec 8th, 2021: [pt-br] Interface Otimista no Kibana com React Query

Esse artigo também está disponível em Inglês

"Como o uso de Optimistic UI (Interface Otimista), pode melhorar a experiência para os usuários."

Introdução

Se você já ouviu falar da biblioteca React Query, deve saber o quão fácil é de utilizar, além de dar ao desenvolvedor controle total sobre data fetching, cache, e dados retornados do servidor. Além disso, você pode facilmente implementar Optimistic UI com React Query.

Optimistic UI (Interface Otimista) é uma técnica já conhecida, que faz com que a Interface do Usuário (UI) mostre feedback imediato das ações que o usuário executa, levando sempre em consideração que a requisição será bem sucedida, essenciamente está relacionada a Mutação de Dados, que é quando os usuários alteram dados que estão no servidor, ou performam side-effects no servidor. Exemplos de ténicas de Optimistic UI são aplicativos de chat, como Whatsapp e Messenger, que quando o usuário clica para enviar a mensagem, a mensagem aparece imediamente na Interface, mesmo que a requisição para o servidor sequer tenha sido iniciada.

Para esse exemplo vamos utilizar uma TODO list (lista de tarefas), porque cobre todos os exemplos necessários de data-fetching e mutações de dados (criar/alterar/excluir).

No formato tradicional

Quando o usuário clica no botão de Add Todo (Criar Tarefa), a interface inicia uma requisição para o servidor e aguarda a resposta. Uma vez que o servidor responde, a interface verifica qual foi o tipo de resposta, se foi uma resposta de sucesso, a Interface é atualizada com a nova tarefa, e habilita a opção para o usuário criar outra tarefa. E se o servidor retornar com uma resposta de erro, a interface se encarrega de exibir uma notificação de erro para o usuário. Com essa abordagem, garantimos uma Interface consistente com o servidor, entretanto o usuário precisa sempre esperar pela resposta do servidor para ver a tarefa na Interface ou para criar outra tarefa.

Article-Todo-1
Exemplo de uma lista de tarefas tradicional

O código com React Query é simples e direto:

// 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')
  },
});

Observe que não é uma experiência ruim, e se ambos a resposta do servidor e renderização da lista atualizad forem rápidas, o usuário não verá como um problema. Mas com aplicação de Optimistic UI, a experiéncia do usuário pode ser aprimorada.

O Formato Otimístico

Numa Interface otimista, os usuários já vêem a tarefa criada assim que clicam no botão de adicionar, sem precisar esperar pela resposta do servidor, levando o usuário a ter uma experiência mais fluída:

Article-Todo-Optimistic-1
Exemplo de lista de tarefas com Optimistic UI

E o código, de acordo com a documentação do React Query, é aproximadamente assim:

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');
  }
});

Como vemos no código, se a requisição falhar, a ação que foi inserida de forma otimista, precisa ser revertida, para evitar que o usuário veja uma interface fora de sincronia com o servidor, ou seja, nesse exemplo sempre após a resposta do servidor a lista de tarefas é inteiramente sincronizada com o servidor, e temos uma interface sempre consistente.

Article-Todo-Optimistic-Error-1
Exemplo de erro, o servidor foi configurado para retornar erro, sempre que a descrição for igual a "force error"

Ressalvas

Entretanto, há certos pontos para ficar atento, por exemplo, o que aconteceria se o usuário tentasse alterar a tarefa antes dela ser criada no servidor? Uma solução simples seria criar uma flag para prevenir interações com a tarefa antes dela ser criada no servidor. Vale notar também, que em alguns casos, recarregar toda a lista de tarefas sempre que se cria uma tarefa pode tornar a experiência ruim, especialmente se a lista é muito grande para renderizar ou tem outros componentes que o usuário pode estar interagindo, e derepente a lista é atualizada. Em alguns casos, pode ser melhor da perspectiva do usuário, mostrar um feedback visual de que a tarefa se encontra em um estado de erro, para que o usuário tente editar, remover ou enviar novamente.

Tudo isso pode ser controlado via código:

Article-Todo-Optimistic-Error-2

Como podemos ver, a experiência agora é ainda mais fluída para o usuário, mesmo na ocorrência de erros, porém isso vem ao custo de que adicionamos mais complexidade para mantermos em nosso projeto:

// 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;
        }),
      }));
    },
  });

Conclusão

Optimistic UI é uma técnica que pode ser utilizado para aprimorar a experiência do usuário, e fazer a interface parecer mais rápida, pois o usuário não precisa aguardar uma requisição terminar antes de executar outra tarefa. Entretanto, não significa que essa será sempre a melhor solução, pois tem casos onde é fundamental termos a resposta do servidor para mostrar ao usuário o próximo estágio (por exemplo uma ação de pagamento), mas vale a pena adicionar Optmistic UI como uma ferramenta a mais para o seu arsenal.

Repositório

O código da lista de tarefas está disponível no Github como um plugin para o Kibana, e as tarefas são gravadas no servidor em índices no Elasticsearch, com exemplos de adicionar / remover e marcar uma tarefa como concluída.

Referências:

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