Explicar o protocolo HTTP na prática: métodos, os status codes que realmente importam no dia a dia e por que o CORS existe.
Configurar uma instância dedicada do Axios (axios.create) com interceptors de request e response, e tratar erros distinguindo error.response de error.request.
Cancelar requisições com AbortController e enviar arquivos com FormData.
Organizar chamadas HTTP em uma camada de serviços, mantendo os componentes livres de detalhes de rede.
Subir uma API falsa com json-server para desenvolvimento e testes.
Criar stores Pinia no estilo setup store, com storeToRefs, ações assíncronas, $reset, $patch, $subscribe e persistência em localStorage.
Conectar o UniEventos a uma API real via camada de serviços e stores, com feedback visual de carregamento, erro e sucesso.
Na Aula 05 você quebrou o UniEventos em componentes com contrato próprio (EventoCard, EventoLista, FiltroEventos, DialogoConfirmacao), extraiu a lógica de dados para o composable useEventos e montou a área administrativa com rotas aninhadas, guards e formulário validado.
Duas limitações ficaram evidentes lá. A primeira: os dados ainda saem de um array estático importado de src/data/eventos.js, que só existe dentro do navegador de quem abriu a página. A segunda: cada componente que chama useEventos() recebe uma cópia própria do estado — o que a área administrativa altera não é necessariamente o que a home enxerga.
Hoje resolvemos as duas. O array vira uma API de verdade, consumida por uma instância dedicada do Axios com interceptors, e o estado sai das refs locais para uma store Pinia, compartilhada por toda a aplicação.
[ ] UniEventos da Aula 05 com componentes extraídos, composable useEventos, rotas aninhadas e formulário validado funcionando.
Você já usou fetch na Aula 03. Antes de trocar por Axios, vale consolidar o que realmente importa saber sobre HTTP para trabalhar com APIs no dia a dia.
Não é preciso decorar os ~60 códigos HTTP — só os que aparecem o tempo todo:
Código
Significado
Quando aparece
200 OK
sucesso, resposta com corpo
GET, PUT, PATCH bem-sucedidos
201 Created
recurso criado
POST bem-sucedido
204 No Content
sucesso, sem corpo de resposta
DELETE bem-sucedido
400 Bad Request
requisição malformada
corpo JSON inválido, campo faltando
401 Unauthorized
não autenticado
token ausente ou inválido
403 Forbidden
autenticado, mas sem permissão
usuário comum tentando ação de admin
404 Not Found
recurso não existe
ID inexistente na URL
409 Conflict
conflito de estado
tentar criar um recurso duplicado
422 Unprocessable Entity
validação de negócio falhou
e-mail em formato inválido, vagas negativas
500 Internal Server Error
erro não tratado no servidor
bug no back-end
📌 Vale gravar: a diferença entre 400 e 422 é sutil, mas costuma confundir: 400 é sobre a forma da requisição (JSON quebrado, tipo errado); 422 é sobre o conteúdo semanticamente inválido de uma requisição bem formada (ex.: vagas: -5).
Toda requisição e resposta HTTP carrega headers — metadados como Content-Type: application/json (informa que o corpo é JSON) e Authorization: Bearer <token> (credencial de autenticação). O corpo em si, na grande maioria das APIs modernas, é um texto no formato JSON — o mesmo JSON.stringify/JSON.parse que você já usa em JavaScript puro.
CORS (Cross-Origin Resource Sharing) é uma política de segurança do navegador, não do servidor. Quando sua aplicação Vue, servida em http://localhost:5173, faz uma requisição para uma API em http://localhost:3000, o navegador considera isso uma requisição cross-origin (origens diferentes: porta diferente já conta como origem diferente, mesmo com o mesmo domínio localhost).
Por padrão, o navegador bloqueia a leitura da resposta de uma requisição cross-origin, a menos que o servidor responda explicitamente autorizando aquela origem, através do header Access-Control-Allow-Origin. Isso existe para impedir que um site malicioso, rodando no seu navegador enquanto você está autenticado em outro site (ex.: seu banco), faça requisições silenciosas para esse outro site usando suas credenciais de sessão sem seu conhecimento.
Para requisições "simples" (GET/POST com Content-Type comum), o navegador já bloqueia a leitura da resposta se o header de autorização não vier certo. Para requisições consideradas "não simples" — como PUT, DELETE, ou POST com Content-Type: application/json combinado com headers customizados — o navegador primeiro envia uma requisição OPTIONS chamada preflight, perguntando ao servidor "você aceita esse tipo de requisição desta origem, com estes headers?". Só se o servidor responder afirmativamente ao preflight é que o navegador envia a requisição real.
⚠️ Atenção: CORS é responsabilidade do servidor resolver (autorizando origens), não do front-end. Se você está desenvolvendo e vê um erro de CORS no console, a correção não é "tentar outra sintaxe no Axios" — é configurar o servidor para responder com os headers corretos. Vamos configurar isso na prática quando construirmos a API Express, na Unidade 3 (Aula 07 em diante). Por hoje, o json-server que vamos usar já vem com CORS liberado por padrão.
fetch é nativo do navegador e funciona bem para casos simples — foi o suficiente até a Aula 03. Mas em uma aplicação real, algumas limitações do fetch pesam:
Recurso
fetch
Axios
Corpo da resposta já convertido em JSON
precisa de .json() manual
response.data já vem pronto
Erros HTTP (4xx/5xx)
não rejeitam a Promise automaticamente
rejeitam a Promise automaticamente
Timeout de requisição
precisa implementar manualmente com AbortController
prop timeout pronta
Interceptors (request/response)
não existe nativamente
suportado nativamente
Instância com configuração padrão (baseURL, headers)
precisa reimplementar um wrapper
axios.create({...}) pronto
Cancelamento
AbortController
AbortController (compatível)
O ponto mais importante da tabela é o segundo: com fetch, uma resposta 404 ou 500não faz a Promise falhar — você precisa checar response.ok manualmente. Isso é uma fonte comum de bugs silenciosos. Com Axios, qualquer status fora da faixa 2xx já cai automaticamente no catch.
⚠️ Atenção: nunca use o axios importado diretamente (import axios from 'axios') espalhado pelos componentes. Sempre crie uma instância dedicada, configurada uma única vez, e reutilize-a em toda a aplicação.
Isso centraliza baseURL (endereço da API), timeout (tempo máximo de espera antes de desistir da requisição) e headers padrão em um único lugar — trocar de ambiente (desenvolvimento → produção) vira uma alteração em um arquivo só.
Um interceptor é uma função que roda automaticamente antes de cada requisição sair (interceptor de request) ou antes de cada resposta chegar ao código que a chamou (interceptor de response):
JavaScript
// src/services/http.js (trecho — adicionar após criar a instância)http.interceptors.request.use((config)=>{consttoken=localStorage.getItem('uniEventosToken')if(token){config.headers.Authorization=`Bearer ${token}`}returnconfig})
Com isso, nenhum componente ou serviço precisa se lembrar de anexar o token manualmente — toda requisição feita através de http já sai com o header Authorization quando há um token salvo. Vamos usar esse mecanismo de verdade na Unidade 3, quando implementarmos login com Firebase.
Interceptor de response — tratar 401 e normalizar erros¶
JavaScript
// src/services/http.js (trecho — adicionar após o interceptor de request)http.interceptors.response.use((response)=>response,(error)=>{if(error.response?.status===401){localStorage.removeItem('uniEventosToken')window.location.href='/login'}returnPromise.reject(error)})
Esse interceptor de response roda para toda resposta com erro, em qualquer lugar da aplicação: se o servidor responder 401 (token expirado ou inválido), o interceptor limpa o token salvo e redireciona para o login — sem que cada chamada de API precise repetir essa lógica.
Tratamento de erro: error.response vs. error.request¶
Ao capturar um erro do Axios, existem três cenários possíveis, e cada um exige um tratamento diferente:
JavaScript
try{constresposta=awaithttp.get('/eventos')console.log(resposta.data)}catch(erro){if(erro.response){// o servidor respondeu, mas com status de erro (4xx, 5xx)console.error('Erro do servidor:',erro.response.status,erro.response.data)}elseif(erro.request){// a requisição foi enviada, mas nenhuma resposta chegou// (servidor fora do ar, sem rede, CORS bloqueando)console.error('Sem resposta do servidor:',erro.request)}else{// erro ao montar a própria requisição (configuração inválida, etc.)console.error('Erro ao configurar a requisição:',erro.message)}}
Essa distinção importa na prática: um erro.response com 404 deve mostrar "evento não encontrado"; um erro.request (sem resposta nenhuma) deve mostrar "não foi possível conectar ao servidor — verifique sua internet".
Em telas com busca "ao digitar" (busca incremental), cada tecla pode disparar uma nova requisição antes da anterior terminar — sem cancelamento, respostas antigas podem chegar depois das novas e sobrescrever dados mais recentes na tela.
JavaScript
letcontrolador=nullasyncfunctionbuscar(termo){if(controlador)controlador.abort()// cancela a busca anterior, se existircontrolador=newAbortController()try{constresposta=awaithttp.get('/eventos',{params:{titulo_like:termo},signal:controlador.signal,})returnresposta.data}catch(erro){if(axios.isCancel(erro)||erro.code==='ERR_CANCELED'){return[]// busca cancelada, não é um erro de verdade}throwerro}}
🔬 Investigue
Remova temporariamente o if (controlador) controlador.abort() acima (ou comente essa linha) e abra a aba Network do DevTools. Digite um termo de busca rápido, letra por letra, sem pausar (ex.: "workshop"). Quantas requisições GET /eventos?titulo_like=... aparecem? Agora observe a coluna de tempo: alguma requisição mais antiga (por uma letra a menos) termina depois de uma mais nova? Se sim, a tela pode acabar mostrando o resultado da busca errada — a resposta que "chegou por último" nem sempre é a da última letra digitada. Restaure a linha removida e repita o teste.
Quando o UniEventos precisar permitir upload de uma imagem de evento (em vez de só uma URL), o corpo da requisição deixa de ser JSON e passa a ser multipart/form-data, construído com FormData:
FormData é uma API nativa do navegador (não específica do Axios) para montar corpos de requisição no formato usado tradicionalmente por formulários HTML com arquivos.
Um erro comum é chamar http.get(...) diretamente dentro de um componente .vue. Isso mistura duas responsabilidades que deveriam ser independentes: como a tela se comporta e como os dados são buscados. Se a API mudar (endpoint renomeado, formato de resposta diferente), você teria que caçar cada componente que faz chamadas HTTP.
A solução é uma camada de serviços: um módulo por recurso, que expõe funções com nomes de negócio (listar, criar, remover) e esconde os detalhes de URL, método HTTP e formato de payload.
Por que os componentes não devem chamar Axios diretamente:
Testabilidade — testar um serviço isolado (mockando http) é muito mais simples do que testar um componente inteiro só para validar uma chamada de API.
Reuso — a mesma função eventosService.listar() é chamada pela HomeView, pela busca administrativa e por um composable, sem repetir a URL em três lugares.
Um ponto único de mudança — se o endpoint /eventos virar /api/v1/eventos, você edita um arquivo, não uma dúzia de componentes.
Separação de camadas — é o mesmo princípio de "não misturar HTML com lógica de banco de dados" que você vai aplicar no back-end, na Unidade 3.
Antes de existir um back-end real (isso vem na Unidade 3, com Express), usamos o json-server: uma ferramenta que transforma um arquivo JSON em uma API REST completa, com poucos minutos de configuração.
Crie o arquivo db.json na raiz do projeto, com este conteúdo (JSON não aceita comentários — não copie nenhuma linha de // para dentro dele):
JSON
{"eventos":[{"id":1,"titulo":"Semana Acadêmica de Computação","descricao":"Palestras e minicursos sobre tendências em tecnologia.","categoria":"palestra","dataHora":"2030-09-29T19:00:00","local":"Auditório Central","vagas":40,"imagemUrl":"https://picsum.photos/seed/evento1/600/300"},{"id":2,"titulo":"Minicurso de Vue.js Avançado","descricao":"Componentização, roteamento e gerenciamento de estado.","categoria":"minicurso","dataHora":"2030-09-15T18:30:00","local":"Laboratório 3","vagas":25,"imagemUrl":"https://picsum.photos/seed/evento2/600/300"},{"id":3,"titulo":"Workshop de Prototipação em Figma","descricao":"Fundamentos de design de interfaces para desenvolvedores.","categoria":"workshop","dataHora":"2030-09-20T14:00:00","local":"Sala 12","vagas":30,"imagemUrl":"https://picsum.photos/seed/evento3/600/300"},{"id":4,"titulo":"Palestra: Carreira em Dados","descricao":"Trilhas profissionais em ciência e engenharia de dados.","categoria":"palestra","dataHora":"2030-10-02T19:30:00","local":"Auditório Central","vagas":50,"imagemUrl":"https://picsum.photos/seed/evento4/600/300"},{"id":5,"titulo":"Minicurso de Banco de Dados NoSQL","descricao":"Modelagem de dados com MongoDB na prática.","categoria":"minicurso","dataHora":"2030-09-22T18:30:00","local":"Laboratório 2","vagas":20,"imagemUrl":"https://picsum.photos/seed/evento5/600/300"},{"id":6,"titulo":"Workshop de Testes Automatizados","descricao":"Testes unitários e de integração em aplicações web.","categoria":"workshop","dataHora":"2030-10-05T14:00:00","local":"Sala 12","vagas":25,"imagemUrl":"https://picsum.photos/seed/evento6/600/300"},{"id":7,"titulo":"Palestra: Ética em Inteligência Artificial","descricao":"Discussão sobre vieses e responsabilidade em sistemas de IA.","categoria":"palestra","dataHora":"2030-10-10T19:00:00","local":"Auditório Central","vagas":60,"imagemUrl":"https://picsum.photos/seed/evento7/600/300"},{"id":8,"titulo":"Minicurso de Node.js e Express","descricao":"Construindo APIs REST do zero.","categoria":"minicurso","dataHora":"2030-09-25T18:30:00","local":"Laboratório 1","vagas":25,"imagemUrl":"https://picsum.photos/seed/evento8/600/300"}],"inscricoes":[]}
⚠️ Atenção
A versão está fixada de propósito. O json-server 1.x (o que npx json-server baixa hoje, sem a versão) removeu a flag --watch e aborta com "unknown argument". Usamos a linha 0.17 porque é a que casa com as rotas de filtro (?titulo_like=) usadas nesta aula. Se preferir a versão nova, a sintaxe passa a ser npx json-server db.json --port 3000 — e alguns filtros mudam de nome.
Isso sobe uma API completa em http://localhost:3000, com:
GET /eventos — lista todos os eventos.
GET /eventos/3 — retorna o evento com id: 3, ou 404 se não existir.
GET /eventos?categoria=palestra — filtro por campo exato.
GET /eventos?titulo_like=vue — busca parcial, sem diferenciar maiúsculas/minúsculas.
POST /eventos — cria um evento novo (retorna 201).
PUT /eventos/3 — substitui o evento 3 inteiro.
PATCH /eventos/3 — atualiza campos específicos do evento 3.
DELETE /eventos/3 — remove o evento 3 (retorna 200 com corpo vazio no json-server).
O --watch faz o json-server recarregar automaticamente sempre que db.json é editado manualmente — útil para resetar o estado de teste durante a aula.
💡 Dica: rode o json-server e o npm run dev do Vite em dois terminais separados. Nenhum dos dois substitui o outro — um serve a API, o outro serve a aplicação Vue.
🔎 Por baixo do capô: o json-server não é o que você vai construir de verdade. Ele existe para permitir treinar consumo de API antes de saber construir uma. Na Unidade 3 (Aulas 07–08), você vai construir a API real do UniEventos com Express, replicando esses mesmos endpoints — e aí vai entender por dentro o que o json-server faz por baixo dos panos.
O problema do prop drilling e do estado espalhado¶
Na Aula 05, você usou provide/inject para dados amplamente compartilhados, e o composable useEventos para lógica reutilizável. Mas o composable tem uma limitação: cada componente que o chama recebe seu próprio estado isolado. Se a HomeView e o CabecalhoApp chamarem useEventos() separadamente, cada um tem sua própria cópia da lista de eventos — atualizar uma não atualiza a outra.
Para estado que precisa ser verdadeiramente compartilhado — a lista de eventos carregada uma vez e usada em várias telas, as inscrições do usuário, o carrinho de um e-commerce —, a resposta é uma store: um único objeto reativo, acessível de qualquer componente, sem precisar passar por props em cada nível da árvore.
O Pinia já vem instalado e registrado se você criou o projeto com a flag --pinia (como fizemos na Aula 02, no npm create vue@latest):
JavaScript
// src/main.js (trecho, já presente no scaffold)import{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'constapp=createApp(App)app.use(createPinia())
🧠 Você sabia?
"Pinia" é a palavra em espanhol para "abacaxi" (piña). O nome foi escolhido por Eduardo San Martin Morote, membro da equipe do Vue, quando ele começou a biblioteca em 2019 como uma experiência pessoal de "como seria o Vuex 5". A experiência foi tão bem recebida pela comunidade que a própria equipe do Vue a adotou oficialmente como gerenciador de estado recomendado no Vue 3 — hoje Vuex está em modo de manutenção, e todo projeto novo usa Pinia.
Pinia suporta dois estilos de declaração de store. Esta trilha usa o setup store — mas você precisa reconhecer os dois, porque o estilo options ainda aparece bastante em projetos e tutoriais existentes.
Options store (parecido com a Options API do Vue 2):
JavaScript
// exemplo — NÃO é o estilo usado nesta trilha, mas você deve reconhecê-loimport{defineStore}from'pinia'exportconstuseContadorStore=defineStore('contador',{state:()=>({valor:0}),getters:{dobro:(state)=>state.valor*2,},actions:{incrementar(){this.valor++},},})
Setup store (usa a Composition API — ref, computed, funções comuns):
JavaScript
// src/stores/contadorStore.js — estilo usado nesta trilhaimport{defineStore}from'pinia'import{ref,computed}from'vue'exportconstuseContadorStore=defineStore('contador',()=>{constvalor=ref(0)constdobro=computed(()=>valor.value*2)functionincrementar(){valor.value++}return{valor,dobro,incrementar}})
No setup store: ref vira state, computed vira getter, função comum vira action — e você retorna explicitamente tudo que deve ficar público. É o mesmo modelo mental que você já usa em <script setup> e em composables, o que reduz a curva de aprendizado: uma store é, na prática, um composable que vive fora de qualquer componente e é compartilhado por todos eles.
📌 Vale gravar: se te perguntarem a diferença entre uma store setup e um composable comum, a resposta central é: uma store é um singleton (uma instância única compartilhada por toda a aplicação, gerenciada pelo Pinia); um composable comum cria estado novo a cada chamada. Veja o box de padrões de projeto logo abaixo.
storeToRefs — por que desestruturar direto quebra a reatividade¶
JavaScript
import{useEventosStore}from'../stores/eventosStore'conststore=useEventosStore()// ERRADO — quebra a reatividadeconst{eventos,carregando}=store
Desestruturar propriedades reativas diretamente de storequebra a reatividade: eventos e carregando viram cópias estáticas do valor no momento da desestruturação, desconectadas da store. Se a store atualizar depois, essas variáveis locais não acompanham.
JavaScript
import{storeToRefs}from'pinia'import{useEventosStore}from'../stores/eventosStore'conststore=useEventosStore()const{eventos,carregando}=storeToRefs(store)// CORRETO — mantém reatividade// ações continuam sendo chamadas direto da store, sem storeToRefsstore.carregarEventos()
storeToRefs converte cada propriedade de state/getter em um ref reativo de verdade, ligado à store original. Ações (funções) não precisam desse tratamento — elas não são reativas, só são chamadas — então continuam sendo acessadas direto de store.nomeDaAcao().
🔎 Por baixo do capô: isso acontece pela mesma razão pela qual desestruturar um reactive() comum quebra a reatividade (você viu isso na Aula 03, ao estudar reactive vs. ref): a store internamente é um objeto reactive, e desestruturar um reactive extrai o valor primitivo naquele instante, perdendo o Proxy que rastreia mudanças. storeToRefs contorna isso criando um ref para cada propriedade, que continua "ligado" ao Proxy original.
// src/stores/eventosStore.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'importeventosServicefrom'../services/eventosService'exportconstuseEventosStore=defineStore('eventos',()=>{consteventos=ref([])constcarregando=ref(false)consterro=ref(null)constcategoriaFiltro=ref('Todas')constbusca=ref('')consteventosFiltrados=computed(()=>{returneventos.value.filter((evento)=>{constbateCategoria=categoriaFiltro.value==='Todas'||evento.categoria===categoriaFiltro.value.toLowerCase()constbateBusca=evento.titulo.toLowerCase().includes(busca.value.toLowerCase())returnbateCategoria&&bateBusca})})asyncfunctioncarregarEventos(){carregando.value=trueerro.value=nulltry{eventos.value=awaiteventosService.listar()}catch(e){erro.value='Não foi possível carregar os eventos. Tente novamente.'}finally{carregando.value=false}}asyncfunctionremoverEvento(id){awaiteventosService.remover(id)eventos.value=eventos.value.filter((e)=>e.id!==id)}asyncfunctionsalvarEvento(dadosEvento){if(dadosEvento.id){constatualizado=awaiteventosService.atualizar(dadosEvento.id,dadosEvento)constindice=eventos.value.findIndex((e)=>e.id===dadosEvento.id)if(indice!==-1)eventos.value[indice]=atualizado}else{constcriado=awaiteventosService.criar(dadosEvento)eventos.value.push(criado)}}function$reset(){eventos.value=[]carregando.value=falseerro.value=nullcategoriaFiltro.value='Todas'busca.value=''}return{eventos,carregando,erro,categoriaFiltro,busca,eventosFiltrados,carregarEventos,removerEvento,salvarEvento,$reset,}})
Repare que carregarEventos, removerEvento e salvarEvento são funções async comuns — Pinia não exige nenhuma sintaxe especial para ações assíncronas. O padrão carregando/erro como state da própria store (em vez de refs locais em cada componente) é o que permite que qualquer tela mostre o estado de carregamento correto, sem duplicar essa lógica.
conststore=useEventosStore()// $reset — no setup store, você define sua própria função $reset (como acima),// pois o Pinia só gera $reset automaticamente para options storesstore.$reset()// $patch — atualiza várias propriedades de uma vez, útil para mudanças em lotestore.$patch({categoriaFiltro:'Palestra',busca:''})// $patch também aceita uma função, útil quando a mudança depende do estado atualstore.$patch((state)=>{state.eventos.push({id:99,titulo:'Evento de teste'})})// $subscribe — reage a qualquer mudança de state da store (ótimo para persistência/log)store.$subscribe((mutation,state)=>{console.log('Store eventos mudou:',mutation.type,state)})
⚠️ Atenção: em uma setup store, $reset() não é gerado automaticamente pelo Pinia (isso só acontece no estilo options store) — por isso a store acima define sua própria função $reset manualmente e a expõe no return. É um detalhe pequeno, mas comum de esquecer.
useInscricoesStore depende de useEventosStore para calcular eventosInscritos — uma composição direta, sem nenhuma cerimônia especial: dentro do setup store, você simplesmente chama useEventosStore() como chamaria em qualquer componente.
A inscricoesStore acima já persiste manualmente, chamando persistir() a cada mudança. Uma alternativa mais genérica é usar $subscribe para persistir qualquer mudança de state automaticamente, sem espalhar chamadas de localStorage.setItem pelas ações:
JavaScript
// src/stores/inscricoesStore.js (trecho — alternativa com $subscribe)exportconstuseInscricoesStore=defineStore('inscricoes',()=>{constidsInscritos=ref(JSON.parse(localStorage.getItem('uniEventosInscricoes')||'[]'))functioninscrever(idEvento){if(!idsInscritos.value.includes(idEvento)){idsInscritos.value.push(idEvento)}}functioncancelarInscricao(idEvento){idsInscritos.value=idsInscritos.value.filter((id)=>id!==idEvento)}return{idsInscritos,inscrever,cancelarInscricao}})
JavaScript
// src/main.js (trecho — assinatura global, fora da store)import{useInscricoesStore}from'./stores/inscricoesStore'constinscricoesStore=useInscricoesStore()inscricoesStore.$subscribe((mutation,state)=>{localStorage.setItem('uniEventosInscricoes',JSON.stringify(state.idsInscritos))})
Ambas as abordagens são válidas; a primeira (persistir dentro da própria ação) é mais explícita e fácil de acompanhar para quem está começando — é a que vamos usar no Mão na massa.
Instale a extensão Vue DevTools no navegador (ou use vite-plugin-vue-devtools, incluído por padrão em muitos scaffolds do create-vue). Na aba Pinia, você vê, em tempo real: todas as stores ativas, o state atual de cada uma, e um histórico de mutações — útil para depurar por que eventosFiltrados não está retornando o que você espera, sem precisar espalhar console.log pelo código.
🧩 Padrão de projeto em uso — Singleton e Decorator¶
Singleton (criacional): uma store Pinia é, por construção, uma instância única compartilhada. Não importa quantas vezes useEventosStore() seja chamado, em quantos componentes diferentes — todos recebem a mesma instância de store, gerenciada internamente pelo Pinia (identificada pelo primeiro argumento de defineStore, 'eventos'). Isso é exatamente o padrão Singleton: garantir que existe no máximo uma instância de um objeto, e fornecer um ponto de acesso global a ela. É a diferença estrutural entre uma store e um composable comum — o composable cria estado novo a cada chamada; a store sempre devolve a mesma instância.
Decorator (estrutural): os interceptors do Axios são um exemplo direto de Decorator. Cada interceptor "envolve" a requisição (ou resposta) original, adicionando comportamento sem alterar o código que originou a chamada — o interceptor de request adiciona o header Authorization; o interceptor de response adiciona tratamento de 401. O componente que chama http.get('/eventos') não sabe (nem precisa saber) que essas camadas extras existem — elas são "decoradas" por fora, de forma transparente.
// src/stores/eventosStore.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'importeventosServicefrom'../services/eventosService'exportconstuseEventosStore=defineStore('eventos',()=>{consteventos=ref([])constcarregando=ref(false)consterro=ref(null)constcategoriaFiltro=ref('Todas')constbusca=ref('')consteventosFiltrados=computed(()=>{returneventos.value.filter((evento)=>{constbateCategoria=categoriaFiltro.value==='Todas'||evento.categoria===categoriaFiltro.value.toLowerCase()constbateBusca=evento.titulo.toLowerCase().includes(busca.value.toLowerCase())returnbateCategoria&&bateBusca})})asyncfunctioncarregarEventos(){carregando.value=trueerro.value=nulltry{eventos.value=awaiteventosService.listar()}catch{erro.value='Não foi possível carregar os eventos. Verifique se o json-server está rodando.'}finally{carregando.value=false}}asyncfunctionremoverEvento(id){awaiteventosService.remover(id)eventos.value=eventos.value.filter((e)=>e.id!==id)}asyncfunctionsalvarEvento(dadosEvento){if(dadosEvento.id){constatualizado=awaiteventosService.atualizar(dadosEvento.id,dadosEvento)constindice=eventos.value.findIndex((e)=>e.id===dadosEvento.id)if(indice!==-1)eventos.value[indice]=atualizado}else{constcriado=awaiteventosService.criar(dadosEvento)eventos.value.push(criado)}}function$reset(){eventos.value=[]carregando.value=falseerro.value=nullcategoriaFiltro.value='Todas'busca.value=''}return{eventos,carregando,erro,categoriaFiltro,busca,eventosFiltrados,carregarEventos,removerEvento,salvarEvento,$reset,}})
Passo 6 — atualizar HomeView.vue para usar a store¶
Vue SFC
<!-- src/views/HomeView.vue -->
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'
const store = useEventosStore()
const { carregando, erro, categoriaFiltro, busca, eventosFiltrados } = storeToRefs(store)
onMounted(() => {
store.carregarEventos()
})
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos disponíveis</h1>
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />
<div v-if="carregando" class="d-flex justify-center pa-8">
<v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
</div>
<v-alert v-else-if="erro" type="error" variant="tonal" title="Erro ao carregar eventos">
{{ erro }}
</v-alert>
<v-alert
v-else-if="eventosFiltrados.length === 0"
type="info"
variant="tonal"
title="Nenhum evento encontrado"
>
Tente ajustar os filtros de categoria ou o termo de busca.
</v-alert>
<EventoLista v-else :eventos="eventosFiltrados" />
</v-container>
</template>
Note os três estados de tela que você já pratica desde a Aula 03 (carregando / erro / vazio), agora alimentados pela store em vez de lógica local — e uma quarta condição implícita (dados carregados com sucesso), coberta pelo v-else final.
Com o json-server rodando em um terminal e npm run dev em outro:
A home carrega os eventos da API — confira na aba Network que a requisição GET http://localhost:3000/eventos acontece de verdade e volta 200.
Inscrever-se em um evento persiste em localStorage: recarregue a página e a inscrição continua marcada.
Editar um evento na área administrativa reflete na home imediatamente (a store é única, compartilhada).
Editar um evento não muda a data nem a imagem dele — só os campos do formulário.
Derrube o json-server (Ctrl+C) e recarregue a home.
Resultado esperado: nos quatro primeiros, tudo funciona sem F5 manual; no quinto, aparece o alerta de erro tratado pela store (erro.value), não uma tela quebrada nem um erro solto no console.
A1. Preveja: com fetch('http://localhost:3000/eventos/999') (id inexistente, responde 404), o código abaixo roda até o fim, sem lançar exceção:
JavaScript
// URL absoluta de propósito: uma URL relativa ('/eventos/999') seria servida// pelo dev server do Vite, que devolve o index.html com status 200 — não o 404 da APIconstresposta=awaitfetch('http://localhost:3000/eventos/999')console.log('Cheguei aqui:',resposta.status)
Trocando fetch pela instância http do Axios, dentro de um try/catch, a mesma chamada cai no catch. Por que essa diferença?
Resultado esperado: com fetch, qualquer status HTTP (incluindo 404/500) resolve a Promise normalmente — só falha de rede rejeita; é preciso checar resposta.ok manualmente. Com Axios, qualquer status fora da faixa 2xx já rejeita a Promise automaticamente, então cai no catch.
A2. Complete a linha que falta no interceptor de request, para que o token salvo seja anexado ao cabeçalho correto:
Resultado esperado: config.headers.Authorization = \Bearer ${token}`— o padrãoBearer ` é o esperado pela maioria das APIs que leem esse cabeçalho.
A3. Em uma frase: por que const { eventos, carregando } = store quebra a reatividade, mas store.carregarEventos() continua funcionando normalmente, sem precisar de storeToRefs?
Resultado esperado: eventos/carregando são state — desestruturar copia o valor naquele instante, perdendo a ligação com o Proxy reativo da store; carregarEventos é uma ação (função), e chamá-la direto de store.carregarEventos() sempre executa o código atual da store, sem precisar de nenhum "encanamento" reativo.
A4. Ache o erro nas linhas abaixo — nenhum componente que usa essa store percebe quando valor muda:
Resultado esperado: return { valor: valor.value, ... } devolve o número puro (0) capturado no instante da criação da store, não o ref — a store deveria devolver valor (o ref inteiro), não valor.value. Assim como desestruturar quebra a reatividade fora da store, devolver .value de dentro da própria store causa o mesmo problema.
A5. Preveja: seu componente chama http.delete('/eventos/7'). A aba Network mostra duas requisições: uma OPTIONS e uma DELETE. Qual delas carrega o cabeçalho Authorization, e por quê?
Resultado esperado: só a requisição DELETE real carrega Authorization — o interceptor de request do Axios roda apenas na chamada que o seu código de fato disparou. O OPTIONS é o preflight, gerado automaticamente pelo navegador (não pelo Axios) para perguntar ao servidor se ele aceita aquele método com aqueles cabeçalhos, antes de enviar a requisição real.
B1. Getter totalPorCategoria na store. Adicione um computed em eventosStore.js chamado totalPorCategoria, que retorna um objeto { palestra: n, minicurso: n, workshop: n } contando eventos de cada categoria. Exiba isso em três v-chip no AdminHomeView.vue.
Resultado esperado: os três v-chip mostram a contagem correta de cada categoria e atualizam sozinhos (sem F5) se um evento for criado, editado ou excluído durante a sessão.
B2.$subscribe para log de auditoria. No main.js, use eventosStore.$subscribe para imprimir no console, a cada mudança, quantos eventos existem na store — útil para depurar sincronizações inesperadas.
Resultado esperado: toda ação que muda eventos (carregar, criar, editar, excluir) imprime uma linha no console com o novo total, sem que você precise espalhar console.log dentro de cada ação da store.
Dica
store.$subscribe((mutation, state) => console.log('eventos:', state.eventos.length)), chamado após app.mount('#app').
B3. Tratamento de erro de rede real. Derrube o json-server propositalmente e force um erro.request (não erro.response). Ajuste eventosStore.carregarEventos para mostrar uma mensagem diferente quando o erro for de conexão (sem resposta) versus quando for um erro HTTP com resposta.
Resultado esperado: com o json-server no ar e um erro de validação simulado, a mensagem exibida fala em "dados inválidos"; com o json-server derrubado, a mensagem é claramente outra (ex.: "não foi possível conectar ao servidor"), sem misturar as duas.
Dica
Dentro do catch, verifique if (e.response) { ... } else if (e.request) { ... }, como na §2.
B4. Persistência de tema com Pinia. Crie src/stores/preferenciasStore.js com uma setup store que guarda o tema atual ('light'/'dark'), persiste em localStorage e é usada pelo CabecalhoApp.vue no lugar da lógica local de useTheme() isolada.
Resultado esperado: o tema escolhido persiste entre recarregamentos de página (F5), lido de preferenciasStore — e não de um ref local isolado que reiniciaria a cada visita.
Dica
A store guarda o nome do tema em um ref; um watch sobre esse ref chama tema.global.name.value = novoValor e localStorage.setItem.
C1. Cancelamento de requisição na busca. Aplique a técnica de AbortController da §2 no eventosService.listar, cancelando a busca anterior sempre que o usuário digitar um novo termo antes da resposta anterior chegar. Prove que funciona sob condições realistas: digite rapidamente, sem pausar, e confirme na aba Network que as respostas antigas não sobrescrevem a lista com resultados desatualizados.
Resultado esperado: digitar uma palavra inteira rapidamente gera várias requisições na aba Network, mas só a última é exibida como bem-sucedida — as anteriores aparecem como "canceled" (ou equivalente), e a lista de eventos na tela sempre corresponde ao último termo digitado, nunca a um termo anterior.
Dica
Guarde a instância de AbortController em uma variável de módulo dentro do próprio serviço, como no exemplo da §2. Trate o erro de cancelamento (axios.isCancel(erro)) separadamente de um erro de verdade — ele não deve acionar a mensagem de erro da store.
Um colega "simplificou" o interceptor de request removendo a checagem, para deixar o código mais enxuto:
JavaScript
// src/services/http.js — trecho com o bug plantadohttp.interceptors.request.use((config)=>{consttoken=localStorage.getItem('uniEventosToken')config.headers.Authorization=`Bearer ${token}`returnconfig})
Agora, mesmo sem nenhum usuário logado, toda requisição sai com o cabeçalho Authorization: Bearer null. Abra a aba Network e confirme. Por que isso é um problema pior do que "só um cabeçalho inútil"?
Critérios de pronto
O interceptor volta a só adicionar o cabeçalho quando existe um token de verdade salvo.
Um teste manual: com localStorage vazio, a aba Network confirma que a requisição sai sem o cabeçalho Authorization (não com o valor literal "null").
Um comentário no código explica por que enviar Authorization: Bearer null pode ser pior do que não enviar cabeçalho nenhum (ex.: um servidor mal implementado poderia tratar a string "null" como um token válido, ou logs de erro ficam poluídos com um "token" que não existe).
Uma segunda checagem: se alguém salvar por engano a string literal "null" no localStorage (com localStorage.setItem('uniEventosToken', null)), seu código também trata esse caso — e não só o null de verdade do JavaScript.
Pistas
localStorage.getItem retorna o valor null do JavaScript quando a chave não existe — mas se alguém já salvou a string "null" por engano, if (token) não pega esse caso, porque a string "null" é truthy.
Confira o valor exato de token (não só sua truthiness) antes de decidir se o cabeçalho deve ser adicionado.
Uma verificação mais robusta: if (token && token !== 'null').
inscricoesStore (e, se você fez o Laboratório B4, preferenciasStore também) implementam persistência em localStorage cada uma com sua própria lógica de leitura/escrita repetida. Extraia isso para uma função reutilizável — no mesmo espírito estrutural dos interceptors do Axios, vistos no box de padrões desta aula.
Critérios de pronto
Uma função usarPersistencia(store, chave) em src/stores/plugins/persistencia.js que: lê o valor salvo do localStorage e usa store.$patch para inicializar o state, e assina store.$subscribe para salvar automaticamente a cada mudança.
inscricoesStore e ao menos uma outra store passam a chamar só usarPersistencia(useInscricoesStore(), 'uniEventosInscricoes') (uma linha), sem repetir localStorage.getItem/setItem manualmente dentro de cada ação.
Remover a chamada de usarPersistencia de uma store faz ela parar de persistir, sem quebrar nenhuma outra funcionalidade — prova de que a persistência está de fato desacoplada da lógica de negócio da store.
Um comentário no arquivo persistencia.js explica por que essa função é, estruturalmente, um Decorator: ela "envolve" uma store existente adicionando um comportamento (persistência) sem que a store precise saber disso.
Pistas
store.$subscribe((mutation, state) => localStorage.setItem(chave, JSON.stringify(state))) cobre a parte de salvar.
Para inicializar, leia o localStorageantes de assinar o $subscribe (senão a leitura inicial dispara uma escrita desnecessária), e use store.$patch(JSON.parse(valorSalvo)) só se valorSalvo existir.
Pinia tem um conceito oficial de "plugin" (pinia.use(...)) que resolve exatamente esse tipo de problema para todas as stores de uma vez — se quiser ir além, pesquise "Pinia plugins" na documentação oficial.
⭐⭐⭐
⭐⭐⭐ Cache de 30 segundos para não repetir a mesma pergunta¶
performanceaxiosjavascript
Toda vez que o usuário volta para a Home vindo do detalhe de um evento, carregarEventos() dispara um novo GET /eventos — mesmo que a lista não tenha mudado nos últimos segundos. Em uma API de verdade (não o json-server local), cada requisição desnecessária custa tempo de rede e carga no servidor. Implemente um cache simples: se os mesmos parâmetros de busca já foram pedidos há menos de 30 segundos, devolva o resultado guardado, sem nova requisição.
Critérios de pronto
Uma camada de cache (um Map em memória, chave = URL + parâmetros, valor = { dados, expiraEm }) na frente de eventosService.listar, ou dentro da própria store.
Chamar carregarEventos() duas vezes seguidas, em menos de 30 segundos, gera uma requisição na aba Network — a segunda vem do cache.
Depois de 30 segundos, uma nova chamada gera uma requisição de verdade — o cache expira, não é permanente.
Uma ação explícita (ex.: botão "Atualizar" na tela) ignora o cache e força uma requisição nova, mesmo dentro da janela de 30 segundos.
Uma tabela no README do projeto autoral compara o número de requisições feitas em um minuto de uso típico, antes e depois do cache (contado na aba Network).
Pistas
Um Map declarado no próprio módulo do serviço (fora de qualquer função) sobrevive entre chamadas — exatamente como a variável controlador do exemplo de AbortController na §2.
Guarde Date.now() + 30000 como o momento de expiração, e compare com Date.now() antes de decidir se serve do cache ou busca de novo.
O botão "Atualizar" pode simplesmente apagar a entrada do cache (ou passar um parâmetro forcar: true que pula a checagem) antes de chamar o serviço normalmente.
🔥
🔥 Boss — Painel de inscrições com filtros persistentes na URL¶
vuepiniaaxiosprojeto
A Unidade 2 terminou. Você sabe componentizar de verdade, sincronizar filtros com a URL (Aula 05), e agora consumir uma API real com Axios e Pinia (hoje). Prove que tudo isso funciona junto, numa única funcionalidade nova: um painel administrativo que mostra quem se inscreveu em cada evento, com filtros que sobrevivem a um F5.
Critérios de pronto
Uma nova rota aninhada /admin/inscricoes (rota-filha de AdminLayoutView), listando, em uma v-data-table, todas as inscrições (useInscricoesStore) já cruzadas com os dados do evento correspondente (useEventosStore) — cada linha mostra o título do evento e a categoria, não só o ID.
Um filtro de categoria (v-select) e um campo de busca por título (v-text-field), ambos sincronizados com a URL via query string (?categoria=...&busca=...), seguindo o padrão da Aula 05 — recarregar a página com uma URL filtrada mantém o filtro aplicado.
Uma store Pinia (setup store) dedicada a essa tela, que compõeuseEventosStore e useInscricoesStore (nenhuma duplica dados das outras duas).
Estados de carregando, erro e "nenhuma inscrição encontrada" tratados visualmente (skeleton/spinner, v-alert de erro, v-alert informativo), exatamente como no restante da aplicação.
Uma ação de "cancelar inscrição" direto da tabela, com v-dialog de confirmação (reaproveitando DialogoConfirmacao.vue da Aula 05) e feedback de v-snackbar ao concluir.
Um teste documentado no README: derrubar o json-server, recarregar a tela e confirmar que aparece uma mensagem de erro clara — nunca uma tela em branco ou quebrada.
Pistas
A store nova pode ter um computed que cruza inscricoesStore.idsInscritos com eventosStore.eventos, parecido com o que eventosInscritos já faz na inscricoesStore — só que organizando por inscrição, não por evento.
Reaproveite a técnica de query string ↔ filtro da Aula 05 (watch + router.push({ query: {...} })) para os dois campos de filtro juntos, não um de cada vez.
useEventosStore() e useInscricoesStore() chamados dentro da nova store funcionam exatamente como no exemplo de composição de stores da seção 5 — Pinia garante que é a mesma instância em qualquer lugar que você chame.
Para o teste de API fora do ar, erro.request (sem erro.response) é o sinal de que não houve resposta nenhuma — mostre uma mensagem diferente desse caso comparado a um erro HTTP normal (ver seção 2).
Crie um db.json com os mesmos dados do seu domínio (mínimo 8 registros) e suba com json-server.
Crie src/services/http.js com instância dedicada, interceptor de request e de response.
Crie um serviço (*Service.js) com pelo menos listar, buscarPorId, criar, remover.
Crie uma store Pinia (setup store) para o recurso principal do seu domínio, com carregando, erro e ao menos uma ação assíncrona.
Conecte pelo menos uma tela à store usando storeToRefs, com feedback de v-snackbar em pelo menos uma ação (criar, excluir ou favoritar).
Critério de pronto: a tela principal carrega dados reais do json-server (não mais do array estático); desligar o json-server e recarregar mostra uma mensagem de erro, não uma tela em branco ou quebrada. Suba o commit.
Referências básicas do plano de curso: capítulos sobre consumo de API e gerenciamento de estado.
Isso encerra a Unidade 2. O Marco 2 do projeto fecha na Aula 08 — veja o quadro de marcos em ../nivel-3/#marcos — com os requisitos completos lá, mas o escopo, resumido em 5 linhas: seu projeto autoral deve consumir dados de uma API (própria ou json-server) através de uma camada de serviços com Axios; ter estado gerenciado por pelo menos uma store Pinia com carregando/erro; refletir esses estados visualmente na interface; persistir algum dado em localStorage; e manter tudo isso rodando em cima da estrutura de rotas e componentes que você já construiu nas Aulas 04 e 05. Comece a organizar seu db.json e sua camada de serviços desde já — não deixe para a última semana.
Na próxima aula o json-server sai de cena: você escreve a unieventos-api de verdade, com Node.js e Express 5, conhece o Firebase (autenticação e Firestore) e aponta o baseURL do http.js desta aula para o seu próprio back-end. É a virada da Unidade 3 — do front que consome uma API falsa para o desenvolvedor full-stack que escreve as duas pontas.