Atualizações 'otimistas' de UI com React Native e React Query

Um pouco sobre atualizações otimistas de UI usando um app React Native como exemplo

LM Luma Montes
4 min read
765 words

Optimistic updates de UI são uma técnica onde as mudanças são refletidas na UI antes de serem confirmadas pelo backend. Essa abordagem melhora a experiência do usuário ao fornecer feedback instantâneo, fazendo o app parecer mais responsivo e fluido.

Este artigo demonstra uma implementação de exemplo em uma aplicação React Native usando react-query, SQLite e um banco de dados local para gerenciar curtidas em posts, mas em um app real poderia ser feito a conexão com uma API.

Por que usar optimistic updates?

  • Melhorar a resposta para o cliente: As atualizações acontecem instantaneamente, reduzindo a latência percebida pelos usuários.
  • Interação Sem Interrupções: Os usuários não precisam esperar pelas respostas do servidor para ver o efeito de suas ações.
  • Recuperação de Erros: Reverter facilmente as mudanças se a resposta do servidor indicar falha.

Exemplo

Vamos ver um exemplo de código simples: dar like e deslike em posts de um app React Native. A ideia central é atualizar a UI assim que o usuário interagir, depois reconciliar com o banco de dados de forma assíncrona.

Repositório no Github: https://github.com/lumamontes/expo-optimistic-ui-updates-tanstack-query

1. Configuração

A aplicação usa as seguintes tecnologias:

  • SQLite: Banco de dados local para armazenar posts e interações dos usuários.
  • react-query: Gerencia consultas, mutações e cache.
  • expo-router: Sistema de navegação para o app.

2. Código

Retornando Posts com react-query

O app busca posts do banco de dados SQLite e verifica se o usuário curtiu cada post:

const { data: posts, isLoading, refetch } = useQuery({
  queryKey: ['posts'],
  queryFn: async () => {
    const result = await db.getAllAsync(`
      SELECT 
        posts.*, 
        CASE 
          WHEN EXISTS (
            SELECT 1 FROM posts_likes WHERE post_id = posts.id AND user_id = ?
          ) THEN 1 
          ELSE 0 
        END AS is_liked
      FROM posts
    `, [userId]);
    return result;
  },
});

Essa consulta recupera posts como o alias (is_liked), indicando o status de curtida do usuário.

Mutação para Atualizações Otimistas

A mutação alterna o status de curtida de um post e realiza o optimistic update:

const togglePostMutation = useMutation({
  mutationFn: async (post) => {
    const likeExists = await db.getFirstAsync(
      'SELECT COUNT(*) as count FROM posts_likes WHERE post_id = ? AND user_id = ?',
      [post.id, userId]
    );

    const newLikedStatus = likeExists?.count === 0;
    const likeCountChange = newLikedStatus ? 1 : -1;
    const updatedLikesCount = post.likes_count + likeCountChange;

    if (newLikedStatus) {
      await db.runAsync('INSERT INTO posts_likes (post_id, user_id) VALUES (?, ?)', [post.id, userId]);
    } else {
      await db.runAsync('DELETE FROM posts_likes WHERE post_id = ? AND user_id = ?', [post.id, userId]);
    }

    await db.runAsync('UPDATE posts SET likes_count = ? WHERE id = ?', [updatedLikesCount, post.id]);

    return {
      ...post,
      is_liked: newLikedStatus,
      likes_count: updatedLikesCount,
    };
  },
  onMutate: async (post) => {
    await queryClient.cancelQueries(['posts']);
    const previousPosts = queryClient.getQueryData(['posts']);

    queryClient.setQueryData(['posts'], (oldPosts) =>
      oldPosts?.map((p) =>
        p.id === post.id
          ? { ...p, is_liked: !post.is_liked, likes_count: post.likes_count + (post.is_liked ? -1 : 1) }
          : p
      )
    );

    return { previousPosts };
  },
  onError: (error, _, context) => {
    if (context?.previousPosts) {
      queryClient.setQueryData(['posts'], context.previousPosts);
    }
  },
  onSuccess: (updatedPost) => {
    queryClient.setQueryData(['posts'], (oldPosts) =>
      oldPosts?.map((post) => (post.id === updatedPost.id ? updatedPost : post))
    );
  },
  onSettled: () => {
    queryClient.invalidateQueries(['posts']);
  },
});

Aspectos principais:

  • onMutate: Atualiza o cache de forma otimista antes da execução da mutação.
  • onError: Reverte o cache para seu estado anterior se a mutação falhar.
  • onSuccess: Garante que o cache reflita o estado do servidor após a mutação.
  • onSettled: Atualiza os dados para garantir consistência.

Interação do Usuário

O usuário pode curtir ou descurtir um post tocando em um botão:

const togglePost = (post) => {
  togglePostMutation.mutate(post);
};

A UI reflete a mudança imediatamente:

<TouchableOpacity onPress={() => togglePost(item)}>
  <Text style={styles.postAction}>
    {item.is_liked ? 'Descurtir' : 'Curtir'}
  </Text>
</TouchableOpacity>

3. Feedback da UI

O FlatList do app renderiza dinamicamente os posts com contagens de curtidas atualizadas:

<FlatList
  data={posts}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({ item }) => (
    <View style={styles.post}>
      <Text>{item.title}</Text>
      <Text>❤️ {item.likes_count} | 💬 {item.comments_count}</Text>
      <TouchableOpacity onPress={() => togglePost(item)}>
        <Text>{item.is_liked ? 'Descurtir' : 'Curtir'}</Text>
      </TouchableOpacity>
    </View>
  )}
/>

Benefícios

  • Feedback Rápido: Os usuários veem suas ações refletidas instantaneamente.
  • Resiliente a Erros: Se uma operação no banco de dados falhar, as mudanças são revertidas.
  • Gerenciamento Eficiente de Cache: react-query garante consistência de dados e chamadas mínimas ao servidor.

Conclusão

Atualizações otimistas de UI proporcionam uma experiência mais suave e responsiva para os usuários ao reduzir a latência percebida. Com bibliotecas como react-query, gerenciar atualizações otimistas se torna eficiente e direto. Combinando isso com SQLite para manipulação de dados locais, os desenvolvedores podem criar aplicativos móveis poderosos e envolventes.