Alura > Cursos de Front-end > Cursos de React > Conteúdos de React > Primeiras aulas do curso React: lidando com padrões e otimizações para aplicações web robustas

React: lidando com padrões e otimizações para aplicações web robustas

Estratégias para lidar com múltiplas fontes de dados - Apresentação

Apresentando o curso e a plataforma Buscante

Olá, estudante da Alura! Eu sou o instrutor Pedro Celestino de Mello.

Audiodescrição: Pedro é um homem branco, com cabelos e barba escuros. Ele veste roupas pretas e está em um ambiente iluminado com as cores rosa e azul.

Seja bem-vindo a mais um curso de React na plataforma da Alura. Para começarmos este curso, apresentamos a plataforma da Buscante, um web app que integra a API do Google Books para realizar chamadas e retornar livros em tempo real. Além disso, temos uma conexão com uma API local de favoritos, permitindo que salvemos nossos livros favoritos em uma estante virtual. Isso facilita quando desejamos ver detalhes, estudar sobre os livros ou adquirir uma versão digital ou impressa.

Explorando desafios e soluções no projeto

Esta é uma aplicação completa, totalmente funcional e pronta para o ambiente de produção. No entanto, surge a questão: o que podemos fazer a mais neste projeto? Considerando que estamos lidando com duas APIs distintas — a API do Google Books e nossa API local de favoritos —, como podemos adaptar nosso projeto para lidar com dados de fontes e tipos diferentes?

Estou aqui para trazer respostas e soluções para esses desafios que encontramos no dia a dia, especialmente quando tratamos de uma visão que abrange tanto o front-end quanto um pouco de back-end.

Estamos aqui para superar a barreira entre back-end e front-end, a fim de entender os problemas, suas causas e como resolvê-los de maneira elegante dentro do nosso código, promovendo uma qualidade excepcional para o nosso projeto.

Detalhando a estrutura do projeto Buscante

O projeto do Buscante está desenvolvido em React com TypeScript. Também utilizamos o Vite para gerenciar todo o nosso build, integrações e outros aspectos. Para facilitar a estilização dentro do projeto, estamos utilizando o Tailwind CSS. Esses são conhecimentos essenciais para iniciarmos nossa exploração no projeto do Buscante.

Ao analisarmos nossa base de código, com o editor de texto aberto, em vez do navegador que exibia o Buscante, observamos que o projeto utiliza o useContext. Temos contextos e providers passando informações, além de alguns hooks personalizados e serviços cadastrados para utilizarmos as APIs individuais.

Implementando o gerenciamento de estado com reducer

Para gerenciar o estado dos favoritos, utilizamos um reducer. Vamos ver como isso é implementado no código:

const favoritesReducer = (state: FavoritesState, action: FavoritesAction): FavoritesState => {
  // ...
};

Esse reducer é responsável por lidar com as ações relacionadas aos favoritos, como adicionar ou remover um livro da lista de favoritos.

Explorando módulos utilitários e funcionalidades avançadas

Adiantando um pouco do que vamos desenvolver, temos módulos utilitários onde utilizaremos managers, serviços e adaptadores. Este será um assunto interessante que abordaremos mais adiante no curso. Vamos aprender sobre cache multicamadas, normalização de dados, adaptação e composição desses dados no front-end. Além disso, teremos uma surpresa no final: a implementação de um request duplicator para tornar nossa aplicação mais robusta e melhorar a experiência do usuário final na aplicação do Buscante.

Utilizando o FavoritesProvider e gerenciando estados assíncronos

Dentro do FavoritesProvider, utilizamos o useReducer para gerenciar o estado dos favoritos e o useMemo para criar um AsyncStateManager:

export const FavoritesProvider = ({ children }: FavoritesProviderProps) => {
  const manager = useMemo(() => new AsyncStateManager(), []);

  const [state, dispatch] = useReducer(favoritesReducer, initialState);

O FavoritesProvider é responsável por fornecer o contexto dos favoritos para o restante da aplicação. Ele utiliza o AsyncStateManager para gerenciar estados assíncronos, como a atualização da lista de favoritos.

Atualizando e gerenciando a lista de favoritos

Para atualizar a lista de favoritos, utilizamos a função refreshFavorites, que faz uma chamada assíncrona para buscar os favoritos e atualiza o estado:

  const refreshFavorites = useCallback(async () => {
    dispatch({ type: 'FETCH_START' });

    const favorites = await executeWithState(manager, 'refresh_favorites', getFavorites);
    if (favorites) {
      dispatch({ type: 'FETCH_SUCCESS', payload: favorites });
    }

    if (manager.getError('refresh_favorites')) {
      dispatch({
        type: 'FETCH_ERROR',
        payload: manager.getError('refresh_favorites') ?? 'Falha ao carregar favoritos',
      });
    }
  }, [dispatch, manager]);

Essa função é crucial para garantir que a lista de favoritos esteja sempre atualizada e reflete as mudanças feitas na API local.

Adicionando e removendo favoritos com segurança

Além disso, temos a função addFavorite, que adiciona um livro à lista de favoritos, verificando antes se ele já não está na lista:

  const addFavorite = async (book: Book) => {
    const isAlreadyFavorite = state.favorites.some((fav) => fav.id === book.id);
    if (isAlreadyFavorite) {
      console.log('[FavoritesContext] Book already favorite, skipping');
      return;
    }

    await executeWithState(manager, 'add_favorite', async () => addToFavorites(book));
    dispatch({ type: 'ADD_FAVORITE', payload: book });

    if (manager.getError('add_favorite')) {
      dispatch({
        type: 'FETCH_ERROR',
      });
    }

Essa função assegura que não adicionamos duplicatas à lista de favoritos, mantendo a integridade dos dados.

Por fim, temos a função removeFromFavorites, que remove um livro da lista de favoritos utilizando um requestDeduplicator para evitar chamadas duplicadas:

export const removeFromFavorites = async (bookId: string): Promise<void> => {
  try {
    return await requestDeduplicator.dedupe('remove_favorite', async () => {
      const response = await fetchWithTimeout(
        `${FAVORITES_API_URL}/${bookId}`,
        {
          method: 'DELETE',
        }
      );

      if (!response.ok) {
        throw new Error(`Falha ao remover dos favoritos: ${response.statusText}`);
      }
      
      cache.set(
        'favorites',
        [... (await getFavorites()).filter((book) => book.id !== bookId)]
      );
    });
  } catch (error) {
    console.error('Error removing from favorites:', error);
    throw error;
  }
};

Verificando a presença de favoritos na lista

E a função isFavorite, que verifica se um livro está na lista de favoritos:

export const isFavorite = async (bookId: string): Promise<boolean> => {
  try {
    const favorites = await getFavorites();
    return favorites.some((book) => book.id === bookId);
  } catch (error) {
    console.error('Error checking if book is favorite:', error);
    return false;
  }
};

Essas funções são fundamentais para a interação com a API de favoritos, garantindo que a aplicação Buscante funcione de maneira eficiente e sem erros.

Estratégias para lidar com múltiplas fontes de dados - Aprendendo a lidar com múltiplos endpoints

Introduzindo a arquitetura de múltiplos endpoints

Vamos iniciar nossa discussão sobre como arquitetar a tratativa de múltiplos endpoints em uma aplicação, utilizando como exemplo a aplicação do Buzz. É importante entender que, mesmo com foco em front-end, precisamos ter conhecimento básico sobre o funcionamento das chamadas para o back-end e como estruturá-las no lado do cliente, que é onde ocorrem as interações em tela com o usuário.

Em aplicações modernas, é comum combinar diferentes fontes de dados. Por exemplo, podemos ter um endpoint para dados de usuário e outro para configurações gerais da aplicação. No contexto do Buzz, temos um endpoint que se comunica com a API do Google Books e outro interno para salvar livros favoritos de um usuário, separando esses dados da API do Google. Além disso, é comum utilizar APIs de terceiros para lidar com pagamentos, especialmente em projetos que não são de bancos ou financeiras.

Explorando padrões de arquitetura

Para organizar e trabalhar com múltiplos endpoints de fontes diferentes, existem padrões de arquitetura que promovem harmonia na aplicação. Um dos maiores desafios é manter o mesmo padrão de tipagem para objetos, pois APIs internas e externas podem diferir. Alterações na tipagem de uma API externa podem causar falhas no lado do cliente, já que confiamos em informações de terceiros que podem mudar.

Vamos explorar alguns padrões de arquitetura, como o Gateway Pattern, que será aplicado mais no lado do back-end. Embora não seja nosso foco como desenvolvedores de front-end, é crucial entender seu funcionamento para discutir melhorias com o time. No lado do cliente, podemos resolver alguns problemas com padrões de arquitetura específicos.

Funcionamento do Gateway Pattern

O Gateway Pattern atua como um serviço intermediário entre o front-end e o back-end, agregando dados de múltiplos endpoints antes de enviá-los ao front-end. Ele pode ser um serviço ou aplicação que acopla informações e as envia ao cliente. Em um exemplo, temos uma aplicação móvel, web e desktop com um Load Balancer distribuindo o tráfego. O API Gateway gerencia autenticação, autorização e login, redirecionando requisições conforme necessário.

No Buzz, se utilizássemos uma arquitetura de Gateway, não acessaríamos diretamente o endpoint da API do Google para buscar livros ou a API interna para favoritar livros. Haveria uma camada extra, o Gateway, que redirecionaria as chamadas para as rotas de serviços gerenciadas internamente. Cada serviço teria seu próprio banco de dados, sendo totalmente separado e desacoplado, acessível através do API Gateway.

Arquitetura de composição de dados no front-end

É importante compreendermos as formas de tratar a composição de dados no back-end, mesmo que não precisemos implementar isso no nosso dia a dia, especialmente se estivermos focados na parte de front-end, que é o objetivo deste curso. No entanto, para quem atua como Full Stack, é essencial não apenas conhecer, mas também desenvolver esse tipo de serviço.

Neste curso, focamos na parte do cliente, utilizando a arquitetura de Client-Side Composition. Isso significa que fazemos a composição de todas as nossas APIs e dados diretamente no nosso código. O front-end realiza chamadas diretas para múltiplos endpoints, eliminando a necessidade de um API Gateway que atua como intermediário. Assim, o próprio front-end é responsável por compor os dados localmente e distribuí-los para os componentes e telas.

Estrutura de aplicações no cliente

Em aplicações de navegador ou qualquer aplicação do lado do cliente, como React, Vue ou Angular, temos componentes que utilizam diferentes APIs. Por exemplo, o componente A pode utilizar a API de usuários, o componente B a API de carrinhos, e o componente C a API de produtos. As chamadas são feitas individualmente, com cada API rodando em portas específicas, como 8001 para usuários, 8002 para carrinhos e 8003 para produtos.

Ao invés de um gateway centralizado, no client-side composition, especificamos explicitamente para cada serviço qual API e porta estamos utilizando. O gerenciamento de estado nas aplicações pode ser feito com Redux, Vuex ou Context API, agregando dados no front-end e utilizando Axios ou Fetch para as chamadas. O Fetch, em particular, tem se mostrado robusto para resolver muitos problemas, embora o Axios ainda seja amplamente utilizado.

Integração e agregação de dados

Toda a integração e agregação de dados, incluindo serviços externos como pagamento e analytics, é feita no código do cliente. A interface é composta a partir dos dados que o front-end obtém através das chamadas.

Back-end for front-end (BFF)

O BFF realiza chamadas diretas para múltiplos endpoints, compondo os dados localmente, semelhante ao client-side composition. No entanto, no BFF, os serviços do back-end são compartilhados, centralizando informações e redirecionamentos, mas com foco no front-end. Isso se assemelha a uma estrutura de microserviços, onde diferentes serviços, como produtos e pagamentos, são integrados para gerenciar informações retornadas ao front-end.

Decisão de arquitetura

Ao decidir sobre a arquitetura, devemos considerar o controle sobre os back-ends e a latência da rede. No caso do Buzz, temos controle sobre apenas um back-end, o que inviabiliza a implementação segura de um API Gateway ou BFF. A complexidade do front-end também influencia a escolha pela Client-side Composition. Se o front-end for simples, optamos por essa abordagem ao invés de modificar toda a arquitetura do back-end.

Estratégias de carregamento de dados

Existem diferentes estratégias de carregamento de dados: sequencial, paralelo e híbrido. No carregamento sequencial, as chamadas são feitas uma após a outra. No paralelo, todas as chamadas são feitas simultaneamente. A estratégia híbrida combina ambas, utilizando chamadas sequenciais quando há dependências entre endpoints e paralelas quando não há.

Para ilustrar a estratégia híbrida, vamos ver um exemplo de código que demonstra como carregar dados de um dashboard de forma eficiente:

// Exemplo de estratégia híbrida
async function loadDashboardData(userId: string) {
    // Primeiro, carrega dados essenciais do usuário
    const user = await fetchUser(userId);

    // Em paralelo, carrega dados que dependem do usuário
    const [preferences, analytics, notifications] = await Promise.all([
        fetchUserPreferences(user.id),
        fetchUserAnalytics(user.id, user.role),
        fetchNotifications(user.id)
    ]);

    return { user, preferences, analytics, notifications };
}

Neste exemplo, começamos carregando os dados essenciais do usuário. Uma vez que esses dados são obtidos, utilizamos Promise.all para carregar em paralelo as preferências do usuário, dados de analytics e notificações, que dependem do usuário já estar carregado. Isso otimiza o tempo de carregamento e evita chamadas desnecessárias caso a API de usuário falhe.

Em resumo, discutimos as arquiteturas para composição de dados de múltiplos endpoints e as estratégias de carregamento. Agora, vamos para o código para entender a dimensão do problema no Buzz e iniciar as alterações para implementar a client-side composition.

Estratégias para lidar com múltiplas fontes de dados - Criando um serviço de composição

Iniciando a composição do lado do cliente

Vamos iniciar a parte de Client-Side Composition em nosso projeto para compor a forma como estamos fazendo as chamadas para nossas APIs. Antes de analisarmos o código, estamos com o editor de texto aberto. Na raiz do projeto, dentro de src, vamos para a pasta "services". Temos o arquivo favoritesApi.ts, que contém toda a lógica referente à utilização da API de favoritos. Também temos o arquivo googleBooksApi.ts, que possui toda a lógica e as chamadas feitas para a API do Google Books, utilizando a versão 1 desse endpoint para realizar as chamadas e obter o retorno dos livros.

Como vimos na apresentação introdutória sobre a arquitetura, precisamos criar um serviço que componha a forma como estamos utilizando nosso código. Vamos criar uma pasta "utils" para desenvolver um módulo utilitário, que será uma classe com funcionalidades adicionais. Utilizaremos essa classe para criar funções utilitárias que desacoplem o código, atualmente 100% baseado no funcionamento do Context, e o mantenham fora do Context da aplicação.

Identificando problemas na aplicação

Com muitos anos de experiência como engenheiros de software, focados em front-end, percebemos que, mesmo com a arquitetura que o useContext fornece, e com o uso de bibliotecas como Redux e React Query, muitas vezes é necessário ter tratativas adicionais no front-end. É isso que vamos começar a implementar agora.

Essa abordagem soluciona um problema significativo. Ao executar a aplicação rapidamente no terminal com npm run dev e acessar localhost:5160, percebemos que a aplicação está rodando. As seções de "sobre" e "contato" funcionam, mas na parte de favoritos, ocorre um erro ao carregar favoritos, com a mensagem "fail to fetch". Isso acontece porque não estamos rodando a API voltada para a busca dos livros favoritos. Ao buscar um livro, como "arquitetura limpa", a pesquisa retorna os livros, mas ao tentar favoritar, nada acontece. Não há retorno sobre o motivo de não podermos salvar o produto como favorito, e não temos feedback sobre o que está acontecendo. Isso ocorre devido a um problema de conexão com a API, que não fica explícito, pois os dados vêm de fontes diferentes e não há uma forma clara de comunicar e cruzar essas informações no front-end.

Criando o serviço de composição de livros

Todo o restante da aplicação funciona. Ao visualizar detalhes de um livro, conseguimos ler e comprar o livro através da API do Google, que está funcionando perfeitamente. No entanto, devido ao problema de conexão com os favoritos, algumas funcionalidades não operam corretamente, pois não estão ligadas à API. Não há indicativo ou informação cruzada informando o motivo do problema. A composição entra para solucionar essa questão. Vamos então começar essa implementação.

Vamos voltar ao nosso editor de texto e criar, dentro da pasta src, uma pasta chamada "utils". Dentro dessa pasta, criaremos nosso serviço de composição, que chamaremos de BookCompositionService.tsx. Começaremos criando uma interface para os dados de composição que utilizaremos neste arquivo. Na linha 1, criaremos a interface ComposedBookData, que estenderá o objeto Book, já existente em nossa aplicação.

interface ComposedBookData extends Book {
    isFavorite: boolean;
    isAvailable: boolean;
    lastUpdated: number;
}

Faremos a importação do tipo Book e adicionaremos as propriedades: se é favorito, se está disponível e a última vez que foi atualizado, que será um timestamp. Salvaremos essas informações como boolean para favorito e disponível, e como numérico para a última atualização.

import type { Book } from '../types/Book';

Adicionaremos também uma interface para o nosso BookCache, que será utilizada quando o livro estiver em cache na aplicação. Falaremos mais sobre cache em vídeos futuros, então não se preocupe com isso agora.

interface BookCache {
    [bookId: string]: {
        data: ComposedBookData;
        timestamp: number;
    };
}

Implementando métodos de cache e validação

Com essas duas interfaces, podemos iniciar a criação da nossa classe BookCompositionService, onde teremos alguns métodos importantes.

class BookCompositionService {
    private cache: BookCache = {};
    private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutos
}

Dentro dela, criaremos um método privado para o cache, que receberá o BookCache. Teremos também um método privado do tipo ReadOnly, que será apenas para referência, onde passaremos a duração do cache de 5 minutos. Para calcular isso em milissegundos, multiplicaremos o número de minutos (5) por 60 (segundos) e por 1000, para obter o resultado em milissegundos.

Agora, criaremos uma função privada chamada isCacheValid, que receberá o bookId e retornará um valor booleano.

private isCacheValid(bookId: string): boolean {
    const cached = this.cache[bookId];
    if (!cached) return false;
    const now = Date.now();
    return (now - cached.timestamp) < this.CACHE_DURATION;
}

Dentro dessa função, verificaremos se o livro está em cache. Se não estiver, retornaremos false, indicando que o cache não é válido. Caso contrário, faremos uma verificação com Date.now() para obter o timestamp atual e retornaremos true se a diferença entre o tempo atual e o cache.timestamp for menor que CACHE_DURATION. Caso contrário, retornaremos false.

Verificando status de favorito e compondo dados

Fecharemos a função privada de validação de cache e criaremos uma função privada para verificar o status de favorito do livro. Para isso, adicionaremos async e chamaremos checkFavoriteStatus, passando o bookId e retornando uma promise booleana.

private async checkFavoriteStatus(bookId: string): Promise<boolean> {
    try {
        return await isFavorite(bookId);
    } catch (error) {
        console.error('Erro ao verificar se o livro é favorito:', error);
        return false;
    }
}

Dentro dessa validação, iniciaremos com um bloco try-catch para tratar erros de forma explícita. O principal motivo de utilizarmos essa arquitetura de composição no lado do cliente é centralizar informações e tratar erros de maneira uniforme em toda a aplicação. Importaremos isFavorite do serviço da nossa API de favoritos.

import { isFavorite } from '../services/favoritesApi';

Agora, precisamos criar uma função para verificar se nosso dado composto está funcionando corretamente. Adicionaremos na linha 38 uma função privada composedBookData, que receberá o livro do tipo Book, o isFavoriteStatus como booleano e retornará o ComposedBookData.

private composedBookData(book: Book, isFavoriteStatus: boolean): ComposedBookData {
    return {
        ...book,
        isFavorite: isFavoriteStatus,
        isAvailable: true,
        lastUpdated: Date.now(),
    };
}

Centralizaremos as informações de livro e favorito dentro dessa função. Como temos duas APIs separadas, que tratam isso de forma diferente, essa composição resolve o problema de falta de sincronização.

Obtendo e atualizando dados compostos

Com o private composedBookData implementado, criaremos uma função pública para obter os dados compostos.

async getComposedBookData(bookId: string): Promise<ComposedBookData | null> {
    if (this.isCacheValid(bookId)) {
        console.log(`[BookCompositionService] Cache válido para o livro ${bookId}, atualizando...`);
        return this.cache[bookId]?.data || null;
    }
    try {
        const [book, favoriteStatus] = await Promise.all([
            getBookById(bookId),
            this.checkFavoriteStatus(bookId),
        ]);
        if (!book) {
            console.log(`[BookCompositionService] Livro ${bookId} não encontrado`);
            return null;
        }
        const composedData = this.composedBookData(book, favoriteStatus);
        this.cache[bookId] = { data: composedData, timestamp: Date.now() };
        return composedData;
    } catch (error) {
        console.error('[BookCompositionService] Erro ao obter dados do livro:', error);
        return null;
    }
}

Para isso, criaremos async getComposedBookData, que receberá o bookId e retornará uma promise do ComposedBookData ou um valor nulo, se não encontrado. Dentro da função, verificaremos o cache com this.isCacheValid(bookId). Se não for válido, retornaremos um console.log e uma mensagem de atualização.

Adicionaremos um bloco try-catch, pois é uma ferramenta importante para tratar erros, especialmente em operações paralelas. Dentro do try, utilizaremos Promise.all para resolver várias promises simultaneamente. Passaremos um array com as promises que queremos resolver: getBookById da nossa Google Books API, passando o bookId, e this.checkFavoriteStatus(bookId). O Promise.all chamará ambas as funções e, se resolver com sucesso, retornará o status.

Se nenhum livro for retornado, exibiremos um console.log informando que o livro não foi encontrado e retornaremos nulo. Na linha 65, faremos a composição dos dados com const composedData = this.composedBookData(book, favoriteStatus). Adicionaremos no cache com this.cache[bookId], passando data como composedData e timestamp como Date.now(). Retornaremos composedData.

No bloco catch, exibiremos uma mensagem de erro: "Erro ao obter dados do livro", seguida pela mensagem de erro, e retornaremos nulo. A função pública para obter dados compostos está concluída.

Invalidando e limpando o cache

Na linha 73, criaremos a função invalidateBook, que receberá o bookId e retornará void.

invalidateBook(bookId: string): void {
    delete this.cache[bookId];
}

Utilizaremos delete this.cache[bookId] para invalidar o cache. Além disso, implementaremos a função clearCache, que definirá this.cache como um objeto vazio.

clearCache(): void {
    this.cache = {};
}

Atualizando o status de favorito

Por fim, criaremos uma função para atualizar o status do livro, como ao alternar o favorito.

updateFavoriteStatus(bookId: string, isFavoriteStatus: boolean): void {
    if (this.cache[bookId]) {
        this.cache[bookId].data.isFavorite = isFavoriteStatus;
        this.cache[bookId].timestamp = Date.now();
    }
}

Essas funções são essenciais para o funcionamento do nosso serviço. Ainda precisamos criar uma função para realizar o fetch dos livros, considerando o cache, mas faremos isso na sequência para finalizar a criação da nossa classe de serviço de composição de dados na aplicação.

Sobre o curso React: lidando com padrões e otimizações para aplicações web robustas

O curso React: lidando com padrões e otimizações para aplicações web robustas possui 271 minutos de vídeos, em um total de 44 atividades. Gostou? Conheça nossos outros cursos de React em Front-end, ou leia nossos artigos de Front-end.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda React acessando integralmente esse e outros cursos, comece hoje!

Conheça os Planos para Empresas