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;
}
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.