Dia 13 — Estado da aplicacao e projeto final
Estado e render: a fonte unica da verdade
O que é estado
Estado é a variável que guarda os dados da aplicação em um único lugar. A fonte única da verdade significa que nenhum dado mora em dois lugares: a <ul> da tela não é a lista de verdade, é o desenho de uma lista que existe em uma variável. Quando a lista muda, o desenho é refeito — nunca o contrário.
O estado é um objeto comum. Um array quando o dado é uma lista; um objeto quando o dado tem nome, tipo e precisam andar juntos.
let estado = { tarefas: [], // cada tarefa e { id, texto, feita } proximoId: 1, filtro: 'todas', };
Um id por item não é vaidade: é o que permite localizar a tarefa depois do clique, quando a posição na tela já não significa nada. Sem id, a solução acaba sendo achar o índice da tarefa na lista, e o índice muda a cada filtro aplicado.
estado se declara com let, não com const: o objeto pode ser trocado inteiro no meio do programa, e é isso que a troca de tela (render) faz.
O caminho dos três passos
Toda interação passa pelo mesmo caminho, sempre na mesma ordem:
- Evento — alguém clicou, digitou, enviou.
- Estado — a função de ação altera
estadoe nada mais. - Render — a função
render()lê o estado e redesenha.
botao.addEventListener('click', () => { estado.tarefas.push({ id: estado.proximoId, texto: 'nova', feita: false }); estado.proximoId += 1; render(); });
O listener não escreve nada na tela. A função de ação não sabe que existe uma <ul>. Essa separação é o que permite trocar o desenho inteiro sem tocar nas regras.
O nome em inglês: state e DOM
Quase toda documentação chama a variável de estado de state e o desenho de DOM. Traduzido: o state guarda, o DOM mostra. A distância entre estado e DOM é o caminho que a função de desenho atravessa a cada vez.
Renderizar a partir do estado quer dizer exatamente isso — ler a variável, criar elementos, jogar na tela — sem que nenhum elemento dependa do clique que o chamou. Um item na tela existe porque existe uma entrada no state, e nunca porque alguém se lembrou de escrever.
O caminho de volta não existe: nenhum listener escreve elemento. Atualizar a tela é trabalho de uma função só — na prática, de quem chama render() depois da alteração. É esse re-renderizar automático que faz a tela refletir mudança sem que cada ação precise saber o formato do desenho.
A função render
render() não recebe argumento e não devolve nada: o estado já está no escopo. Ela apaga o container e recria os filhos a partir do estado. Rodar duas vezes seguidas dá o mesmo resultado, e rodar depois de uma mudança na tela conserta a tela.
| Parte | O que faz | Assinatura | |
|---|---|---|---|
| Limpar o container | apaga os filhos sem apagar o próprio elemento | `elemento.replaceChildren(...filhos: (Node \ | string)[]): void` |
| Criar um item | cria o elemento, ainda fora da tela | document.createElement(nomeDaTag: string): HTMLElement | |
| Jogar na tela | insere os elementos criados | `elemento.append(...filhos: (Node \ | string)[]): void` |
replaceChildren é a forma curta de "apaga tudo e põe isto aqui". Aceita zero argumento (apaga), um argumento ou vários. É o que faz a tela nunca sobrar item de uma tarefa excluída.
Limpar container é esse nome mesmo: elLista continua na página, o que muda é o conteúdo interno. O replaceChildren() sem argumento é o botão de apagar tudo; com os filhos novos, é o desenho inteiro.
function render() { elLista.replaceChildren( ...estado.tarefas.map((tarefa) => { const li = document.createElement('li'); li.textContent = tarefa.texto; if (tarefa.feita) li.classList.add('feita'); return li; }) ); }
textContent e não innerHTML: o texto da tarefa vem de um campo que a pessoa digitou, e innerHTML interpretaria <b> como marcação. Todo dado que veio do usuário entra por textContent.
Recriar tudo é mais barato que consertar
A alternativa é criar um <li> só quando a tarefa é nova, e procurar e apagar só quando ela é excluída. O caminho curto quebra no quinto tipo de mudança — reordenar, desfazer, filtrar, corrigir um texto — e o conserto passa a ser querySelector seguido de "e se a ordem mudou?". Substituir tudo custa alguns milissegundos em uma lista de tarefas e remove a classe inteira de bugs em que a tela discorda do estado.
O preço: um <input> que a pessoa está digitando perde o foco a cada render(). É por isso que a busca, em vez de refiltrar a lista inteira, pode ser lida no input do campo sem redesenhar o campo.
Re-renderizar é a palavra do clique seguinte
Renderizar é desenhar a partir do estado; re-renderizar é desenhar de novo depois que o estado mudou, com o mesmo código e sem parâmetro novo. O render() chamado pela segunda vez não sabe nem se trata da quarta tarefa ou da décima: ele só re-renderiza.
Na prática, o re-renderizar corrige a tela quando alguém mexeu nela por fora. Apagar a <ul> na mão deixa a tela mentindo, porque o estado continua intacto — basta chamar render() outra vez e a lista volta inteira, sem guardar nada, sem desfazer, sem recarregar a página.
O teste que separa os dois casos: se a lista reflete o estado, mexer só na tela some no re-renderizar seguinte. Se ela não reflete o estado, existe um caminho em que ninguém atualiza a tela depois de alterar o estado — e é aí que o render() foi esquecido.
Derivado é o que não se guarda
Quantas tarefas faltam, quantas estão feitas, o total que aparece no rodapé: nenhum desses números é estado. Todos são calculados na hora, a partir do estado, por uma função. O que se guarda é o que a pessoa digitou; o que se calcula é o que a página mostra.
const concluidas = () => estado.tarefas.filter((t) => t.feita); const pendentes = () => estado.tarefas.filter((t) => !t.feita);
Estado derivado guardado em variável é o que envelhece mal: existe um caminho em que a tarefa é concluída, o derivado não é recalculado, e o contador passa a mentir. Sem variável, não há o que ficar velho.
Estado lido de dois lugares na tela é estado com dois donos. O sintoma é a lista continuar mostrando o que foi excluído, e o culpado ser sempre "achei que o
renderestava depois".
Quando o estado substitui o DOM
A pergunta que separa o código curto do código comprido: quem decide o que aparece na tela? Se a resposta for o addEventListener, o estado é dispensável e o programa cresce caso a caso. Se a resposta for render(), o estado é a fonte única.
O sintoma prático do problema é aparecer uma função atualizaContador() que alguém precisa lembrar de chamar depois de cada operação nova. Quando existe uma função que precisa ser lembrada, o desenho está espalhado.
Uma tela de lista costuma passar por quatro estados sem mudar de código: vazia, com itens, com um filtro que esconde parte, e sem resultado nenhum de busca. São quatro saídas do mesmo render, lidas do mesmo estado.
Exemplo
'use strict'; // ============================================================ // AULA 1 - Estado e render: a fonte unica da verdade // ============================================================ // A pagina ja nasce montada: um resumo, uma <ul> VAZIA e tres botoes. // A <ul> comeca vazia de proposito. Quem enche ela e o estado, nunca o clique. document.body.innerHTML = ` <p id="resumo"></p> <ul id="lista"></ul> <button id="nova" type="button">nova tarefa</button> <button id="concluir" type="button">concluir a primeira</button> <button id="na_mao" type="button">apagar a <ul> na mao</button> `; const elLista = document.querySelector('#lista'); const elResumo = document.querySelector('#resumo'); const btnNova = document.querySelector('#nova'); const btnConcluir = document.querySelector('#concluir'); const btnNaMao = document.querySelector('#na_mao'); // ------------------------------------------------------------ // 1. O ESTADO // A fonte unica da verdade e esta variavel e nada mais. Ela guarda // os dados; a tela guarda apenas o desenho, e pode ser refeita a // qualquer momento a partir daqui. // ------------------------------------------------------------ let estado = { tarefas: [], // cada tarefa e { id, texto, feita } proximoId: 1, // contador do estado, nao um Math.random() solto no DOM }; function novaTarefa(texto) { // A mutacao acontece AQUI, no estado. O listener so chama esta funcao. estado.tarefas.push({ id: estado.proximoId, texto, feita: false }); estado.proximoId += 1; } function alternarConclusao(id) { const tarefa = estado.tarefas.find((t) => t.id === id); if (tarefa) tarefa.feita = !tarefa.feita; } // ------------------------------------------------------------ // 2. OS DERIVADOS // Nada disto e guardado em variavel. Todo mundo le o estado e // calcula na hora. Valor guardado no estado e o que envelhece // mal: esquece de atualizar e passa a mentir. // ------------------------------------------------------------ const concluidas = () => estado.tarefas.filter((t) => t.feita); const pendentes = () => estado.tarefas.filter((t) => !t.feita); // ------------------------------------------------------------ // 3. A FUNCAO RENDER // Le o estado e redesenha a tela inteira. Nao recebe argumento e // nao devolve nada: o unico parametro que importa e o estado, e ele // ja esta no escopo. Rodar duas vezes seguidas da o mesmo resultado. // ------------------------------------------------------------ function render() { // replaceChildren(...) apaga o que estava la e poe os filhos novos de uma vez. elLista.replaceChildren( ...estado.tarefas.map((tarefa) => { const li = document.createElement('li'); li.textContent = tarefa.texto; if (tarefa.feita) li.classList.add('feita'); return li; }) ); const total = estado.tarefas.length; elResumo.textContent = `${concluidas().length} de ${total} concluidas, ` + `${pendentes().length} pendentes`; } // ------------------------------------------------------------ // 4. OS EVENTOS // O listener tem duas linhas de trabalho: mudar o estado e chamar // o render. Ele nunca escreve na <ul>. // ------------------------------------------------------------ btnNova.addEventListener('click', () => { novaTarefa('tarefa ' + estado.proximoId); render(); }); btnConcluir.addEventListener('click', () => { const primeira = estado.tarefas[0]; if (primeira) alternarConclusao(primeira.id); render(); }); // O botao errado, de proposito: ele mexe SÓ na tela e deixa o estado como // estava. Serve para mostrar o tipo de bug que a fonte unica evita. btnNaMao.addEventListener('click', () => { elLista.replaceChildren(); elResumo.textContent = '(a tela foi apagada na mao)'; }); // ------------------------------------------------------------ // 5. O ROTEIRO // Para mostrar que o listener funciona, o evento precisa DISPARAR. // dispatchEvent e o clique programatico: o mesmo que o navegador // faria se a pessoa clicasse com o mouse. // ------------------------------------------------------------ function mostrar(titulo) { const naTela = elLista.querySelectorAll('li').length === 0 ? '(vazia)' : [...elLista.querySelectorAll('li')] .map((li) => li.textContent + (li.classList.contains('feita') ? ' [feita]' : '')) .join(' | '); console.log('\n--- ' + titulo + ' ---'); console.log(' estado.tarefas: ' + JSON.stringify(estado.tarefas)); console.log(' estado.proximoId: ' + estado.proximoId); console.log(' <ul> na tela : ' + naTela); console.log(' #resumo : ' + elResumo.textContent); } render(); mostrar('1. estado vazio, com o render chamado uma vez'); btnNova.dispatchEvent(new Event('click')); btnNova.dispatchEvent(new Event('click')); mostrar('2. dois cliques em "nova tarefa"'); btnConcluir.dispatchEvent(new Event('click')); mostrar('3. um clique em "concluir a primeira"'); // O estado nao mudou nada, so a tela. A lista esta mentindo. btnNaMao.dispatchEvent(new Event('click')); mostrar('4. a tela apagada na mao, com o estado intacto'); // Um unico render() resolve: a tela e refeita a partir do estado. render(); mostrar('5. o mesmo render() de novo, e a tela volta'); // Conclusao impressa: a tela e uma consequencia, nao uma causa. console.log('\nOs dados vivem em um lugar so. Derivar e mais barato que sincronizar.');
Saída real
--- 1. estado vazio, com o render chamado uma vez ---
estado.tarefas: []
estado.proximoId: 1
<ul> na tela : (vazia)
#resumo : 0 de 0 concluidas, 0 pendentes
--- 2. dois cliques em "nova tarefa" ---
estado.tarefas: [{"id":1,"texto":"tarefa 1","feita":false},{"id":2,"texto":"tarefa 2","feita":false}]
estado.proximoId: 3
<ul> na tela : tarefa 1 | tarefa 2
#resumo : 0 de 2 concluidas, 2 pendentes
--- 3. um clique em "concluir a primeira" ---
estado.tarefas: [{"id":1,"texto":"tarefa 1","feita":true},{"id":2,"texto":"tarefa 2","feita":false}]
estado.proximoId: 3
<ul> na tela : tarefa 1 [feita] | tarefa 2
#resumo : 1 de 2 concluidas, 1 pendentes
--- 4. a tela apagada na mao, com o estado intacto ---
estado.tarefas: [{"id":1,"texto":"tarefa 1","feita":true},{"id":2,"texto":"tarefa 2","feita":false}]
estado.proximoId: 3
<ul> na tela : (vazia)
#resumo : (a tela foi apagada na mao)
--- 5. o mesmo render() de novo, e a tela volta ---
estado.tarefas: [{"id":1,"texto":"tarefa 1","feita":true},{"id":2,"texto":"tarefa 2","feita":false}]
estado.proximoId: 3
<ul> na tela : tarefa 1 [feita] | tarefa 2
#resumo : 1 de 2 concluidas, 1 pendentes
Os dados vivem em um lugar so. Derivar e mais barato que sincronizar.
Projeto final: lista de tarefas com filtro e busca
O programa em quatro partes
O projeto final junta o que o trimestre inteiro cobriu: entrada do usuário, addEventListener, criação de elementos e estado. A lista tem adicionar, concluir, excluir, filtro por situação, busca e persistência em localStorage. Tudo isso cabe em quatro partes, na mesma ordem em que o arquivo se lê.
| Parte | Onde fica | O que faz |
|---|---|---|
| Estado | let estado = { tarefas, filtro, busca, proximoId } | a fonte única da verdade |
| Ações | adicionar, alternar, excluir | mudam o estado e nada mais |
| Leitura | porSituacao, porBusca, visiveis | calculam o que aparece |
| Render | render() | lê o estado e redesenha |
A regra que sustenta a ordem: as ações não conhecem a tela, a tela não conhece as regras. Um render no fim de cada ação, e nada mais.
O formulário precisa de preventDefault
Enviar um <form> recarrega a página inteira e o estado vai embora. O submit precisa ser interceptado antes disso.
form.addEventListener('submit', (evento) => { evento.preventDefault(); if (adicionar(campoTexto.value)) campoTexto.value = ''; render(); salvar(); });
O retorno de adicionar diz se a tarefa foi criada. Um campo só com espaços é texto vazio depois do trim(), e por isso não vira tarefa — o campo é limpo só quando a criação aconteceu, e o contador continua dizendo que não há tarefa nenhuma.
Os três botões, três assinaturas
As três ações da tabela do topo existem com esses nomes e esses parâmetros, e cada uma devolve o que o listener precisa saber:
| Ação | Assinatura | Efeito |
|---|---|---|
adicionar | adicionar(texto: string): boolean | cria a tarefa e devolve true; devolve false se o texto é vazio |
alternar | alternar(id: number): void | inverte feita da tarefa com aquele id |
excluir | excluir(id: number): void | remove a tarefa do array |
Remover tarefa é a única ação que troca o array inteiro em vez de mexer em um item: as outras duas alteram um objeto que já existe, e essa cria um array novo.
function excluir(id) { estado.tarefas = estado.tarefas.filter((t) => t.id !== id); }
É essa forma de filter que remove a tarefa: o array novo sai sem aquele id, e o array velho é descartado inteiro. alternar não remove — marcar como feita e o mesmo botão que desfaz, porque o estado da tarefa cabe em uma chave booleana.
O campo de entrada é um <input type="text"> dentro do <form>, com um <button type="submit"> ao lado. Submit pertence ao formulário, não ao botão: apertar o botão, apertar Enter dentro do campo ou chamar form.requestSubmit() disparam o mesmo submit, e o preventDefault cobre os três caminhos.
O caminho de adicionar tarefa tem três passos e nenhum quarto: ler campoTexto.value, chamar adicionar(texto), e só então limpar o campo. Esquecer o trim() dentro da ação, em vez de no listener, faz o mesmo texto voltar a entrar pela metade.
Delegação: um listener para a lista inteira
Os botões de concluir e excluir nascem dentro do render, e são recriados a cada mudança. Se cada <li> registrasse o próprio listener, eles sumiriam junto com o elemento — ou, pior, duplicariam a cada redesenho.
A saída é um listener só no <ul>, que descobre o alvo no momento do clique:
elLista.addEventListener('click', (evento) => { const botao = evento.target.closest('button'); const li = botao.closest('li'); const id = Number(li.dataset.id); if (botao.classList.contains('alternar')) alternar(id); render(); });
event.target é o elemento que foi apertado; event.currentTarget é o <ul>, que é quem tem o listener. closest() sobe a árvore até achar o ancestral que casa com o seletor, e é o que devolve o <li> da tarefa a partir do botão. dataset.id é o atributo data-id lido como objeto.
dataset só funciona porque o id foi escrito no elemento:
const li = document.createElement('li'); li.dataset.id = String(tarefa.id);
Sem ele, o clique chega ao <ul> e não há como saber qual tarefa foi apertada. É o mesmo id do estado, agora na tela — e é ele que amarra um clique a um dado.
Os dois botões dentro do <li> são distinguidos pela classe, e essa classe é o que o listener testa: alternar marca como feita, excluir remove a tarefa. Como os dois terminam em render() e salvar(), um único listener na lista resolve as duas operações — trocar o classList.contains('alternar') por 'excluir' é a diferença entre marcar uma linha e apagá-la.
Filtro e busca, em conjunto
Filtro e busca são dois filtros independentes, aplicados um depois do outro. Nenhum dos dois altera o estado: são leituras. E é por isso que apagar o texto da busca traz a lista de volta inteira, sem nenhum código de "desfazer filtro".
const porSituacao = () => { if (estado.filtro === 'feitas') return estado.tarefas.filter((t) => t.feita); if (estado.filtro === 'pendentes') return estado.tarefas.filter((t) => !t.feita); return estado.tarefas; }; const visiveis = () => porBusca(porSituacao());
visiveis() é o que o render desenha. O estado continua com as tarefas todas: filtrar esconde, não apaga. O contador do rodapé mostra os dois números, o total real e o que está na tela, porque são coisas diferentes e a diferença é informação.
Essa é a ideia do filtro combinado: porSituacao entrega uma lista, porBusca recebe essa lista e entrega outra menor, e visiveis só costura as duas. Filtrar tarefas por situação e depois por texto dá o mesmo resultado, em qualquer ordem, porque nenhum dos dois filtros enxerga o outro — a ordem decide, o conteúdo não.
Case-insensitive é uma linha, não uma biblioteca
A busca ignora maiúscula e acento nos dois lados, para que "PARA", "para" e "pára" achem "ligar para o suporte":
const termo = estado.busca.trim().toLowerCase(); if (termo === '') return lista; return lista.filter((t) => t.texto.toLowerCase().includes(termo));
Comparar case-insensitive é normalizar as duas pontas antes do includes: o que a pessoa digitou e o que está gravado passam pelo mesmo toLowerCase(). O trim() antes do toLowerCase() importa — o termo " " não pode virar um filtro que esconde quase tudo.
O evento de busca é input, não change. change só dispara ao sair do campo, e quem digita "pao" num teclado lento não veria nada acontecendo. input dispara a cada tecla, e é o que permite filtrar enquanto a pessoa escreve.
A busca não chama salvar(). Digitar "pizza" e recarregar a página não deve deixar a aplicação presa naquele termo: o filtro é leitura, o texto digitado é estado de sessão, e só a lista é estado durável.
Persistência em localStorage
O localStorage guarda texto, e ele é perdido quando a aba fecha. O acesso é por window.localStorage, o que funciona dentro de uma página normal e também no exemplo rodado com jsdom.
| Método | Assinatura | O que faz | |
|---|---|---|---|
| Gravar | setItem(chave: string, valor: string): void | cria ou substitui o valor da chave | |
| Ler | `getItem(chave: string): string \ | null` | devolve null se a chave não existe |
| Apagar | removeItem(chave: string): void | remove a chave | |
| Limpar | clear(): void | apaga tudo do domínio |
O array vira texto na ida e volta na volta:
function salvar() { const { tarefas, proximoId } = estado; window.localStorage.setItem(CHAVE, JSON.stringify({ tarefas, proximoId })); } function carregar() { const salvo = window.localStorage.getItem(CHAVE); if (salvo === null) return null; // primeira visita return JSON.parse(salvo); }
JSON.parse pode lançar SyntaxError se o texto estiver quebrado — e ele fica quebrado: alguém pode editar a chave pelo DevTools, ou uma versão anterior do programa gravou um formato diferente. O JSON.parse fica dentro de um try/catch, e o catch devolve null para a aplicação começar vazia em vez de tela branca.
Persistir é decidir o que sai do estado. salvar() grava só { tarefas, proximoId } e deixa filtro e busca de fora, porque são leituras de sessão: quem volta amanhã quer a lista, não o termo "pizza" que ficou no campo. Recuperar estado é o caminho inverso — carregar() devolve null na primeira visita, e quando devolve um objeto o programa atribui os campos ao estado antes do primeiro render().
const salvo = carregar(); if (salvo) { estado.tarefas = salvo.tarefas; estado.proximoId = salvo.proximoId; } render();
A ordem é o que importa: recuperar estado antes de renderizar. Renderizar com o estado ainda vazio mostra uma lista vazia e passa a impressão de que a persistência não funciona.
O id faz parte do estado salvo, e o proximoId vai junto. Sem ele, recarregar a página zera o contador e a próxima tarefa nasce com o mesmo id de uma que ainda está na lista — e aí um clique marca as duas.
localStorageé por origem:file://ehttps://exemplo.localnão dividem o mesmo armazenamento. Abrir o mesmo arquivo por caminho diferente, em outro navegador ou em aba anônima, começa vazio. Isso não é bug, é o navegador separando o espaço de cada origem.
Erros comuns
O render esqueceu de ser chamado. A ação funcionou e a tela não mudou. A regra é mecânica: a ação altera o estado, quem altera o estado chama o render. Se o clique só chama alternar(id), falta o render().
Filtro que apaga em vez de esconder. estado.tarefas = estado.tarefas.filter(...) dentro da busca destrói a lista original, e a busca "voltar sozinha" deixa de funcionar. Filtro é cálculo sobre uma cópia.
querySelector guardado em variável que vira null. Elementos criados no render não existem no momento do addEventListener. O listener vai no elemento que existe sempre (o <ul>, o <form>) e o querySelector fica para dentro do render.
innerHTML montando <li>. O texto vem de um campo digitado; innerHTML interpretaria o que a pessoa escreve. Todo dado do usuário entra por textContent ou value.
O campo de busca perde o foco a cada tecla. Acontece quando o render redesenha o <input> junto com a lista. O campo precisa estar fora do container que o render substitui.
Contador que mente. Ele aparece quando alguém guarda o total em variável no clique, em vez de calculá-lo. Quantas tarefas existem é .length do array; quantas tarefas estão concluídas é .filter((t) => t.feita).length. As tarefas concluidas são as que têm feita em true — e essa contagem muda no exato clique que alterna a tarefa, o que torna o número guardado incompatível com o clique seguinte.
Checklist de revisão
- Existe um único objeto
estado, e orenderlê dele? - Toda ação que altera o estado termina chamando
render()? - Nenhum listener escreve direto no container da lista?
- Cada item da lista tem
id, e o clique acha o item pordataset.id? - Filtro e busca são calculados, nunca gravados no estado?
- O
localStoragesó recebeJSON.stringify, e oJSON.parseestá protegido? - A tela tem um jeito de ficar vazia, e esse vazio é desenhado de propósito?
Para onde vai o próximo trimestre
Este projeto roda inteiro no navegador: o estado vive em memória enquanto a aba estiver aberta, e o localStorage é o limite do que ele guarda. O próximo passo é o mesmo programa com o estado do lado do servidor — fetch para buscar e salvar, e a lista sobrevivendo a qualquer navegador, qualquer máquina e qualquer prazo.
A mudança do navegador para o servidor acontece em um único arquivo: o de rede. Todo o resto — o estado, as três ações, os dois filtros, o render — continua igual, porque nenhuma dessas partes nunca falou com o localStorage diretamente; elas falam com salvar() e carregar().
O que muda é o lugar da fonte única da verdade, não o desenho. render() continua lendo um estado e redesenhando a tela; o que muda é que a mutação do estado passa a ser uma resposta do servidor, e cada espera vira um estado de carregamento que também precisa ser desenhado.
A passagem do navegador para o servidor tem dois lados, e vale saber qual é cada um: Node.js é o mesmo motor do JavaScript rodando fora do navegador, com node arquivo.js no terminal e o package.json na mesma pasta; o fetch continua rodando na página e só muda o endereço de destino, de localStorage para http://localhost:3000/tarefas.
| Peça | Onde roda | Assinatura |
|---|---|---|
| Servidor | Node.js | createServer(puxar: (req, res) => void): Server |
| Ler do servidor | navegador, no carregamento | fetch(url: string): Promise<Response> |
| Gravar no servidor | navegador, depois da ação | fetch(url: string, opcoes: { method: string, body: string }): Promise<Response> |
O await entra nos dois lados: no servidor, await espera o corpo da requisição antes de responder; no navegador, await fetch(...) espera a resposta antes de mexer no estado. A regra da aula continua valendo — o render só é chamado depois que o estado mudou, e agora ele só é chamado depois que o servidor respondeu.
Exemplo
'use strict'; // ============================================================ // AULA 2 - Projeto final: lista de tarefas com filtro e busca // ============================================================ // O programa inteiro, em um arquivo so: adicionar, concluir, excluir, // filtrar, buscar e guardar no localStorage. Tudo passa pelo mesmo // caminho de tres passos: evento -> estado -> render. document.body.innerHTML = ` <form id="form"> <input id="texto" type="text" placeholder="nova tarefa"> <button id="adicionar" type="submit">adicionar</button> </form> <input id="busca" type="text" placeholder="buscar"> <select id="filtro"> <option value="todas">todas</option> <option value="pendentes">pendentes</option> <option value="feitas">feitas</option> </select> <p id="contador"></p> <ul id="lista"></ul> `; const form = document.querySelector('#form'); const campoTexto = document.querySelector('#texto'); const campoBusca = document.querySelector('#busca'); const seletorFiltro = document.querySelector('#filtro'); const elContador = document.querySelector('#contador'); const elLista = document.querySelector('#lista'); const CHAVE = 'tarefas'; // ------------------------------------------------------------ // 1. O ESTADO // Tarefas, filtro e busca moram juntos no mesmo objeto, porque a // tela depende dos tres ao mesmo tempo. E a fonte unica da verdade: // tudo que voce ve na pagina e calculado daqui. // ------------------------------------------------------------ let estado = { tarefas: [ { id: 1, texto: 'comprar pao', feita: true }, { id: 2, texto: 'estudar JavaScript', feita: false }, { id: 3, texto: 'ligar para o suporte', feita: false }, ], filtro: 'todas', // 'todas' | 'pendentes' | 'feitas' busca: '', proximoId: 4, }; const proximoId = () => estado.proximoId++; // ------------------------------------------------------------ // 2. PERSISTENCIA // localStorage so aceita texto. O array vira texto com // JSON.stringify e volta com JSON.parse. O acesso e por // window.localStorage. // ------------------------------------------------------------ function salvar() { // JSON.stringify sem o campo proximoId: o contador recomeca no // ultimo id, e e assim que se evita o id repetido depois do F5. const { tarefas, proximoId } = estado; window.localStorage.setItem(CHAVE, JSON.stringify({ tarefas, proximoId })); } function carregar() { const salvo = window.localStorage.getItem(CHAVE); if (salvo === null) return null; // primeira visita: devolve null // O JSON pode estar quebrado (alguem editou a chave na mao). O // try/catch impede que isso derrube a pagina inteira. try { const dados = JSON.parse(salvo); if (!Array.isArray(dados.tarefas)) return null; return dados; } catch (erro) { console.log(' (localStorage com JSON invalido: ' + erro.name + ')'); return null; } } // A pagina "recarrega" lendo o que sobrou da visita anterior. O roteiro // abaixo comeca de zero, para mostrar a lista vazia. const salvo = carregar(); if (salvo) { estado.tarefas = salvo.tarefas; estado.proximoId = salvo.proximoId; } // ------------------------------------------------------------ // 3. AS ACOES // Cada acao mexe no estado e deixa o resto para quem chama. Assim // a mesma acao serve para o clique, para o atalho de teclado e para // o teste automatico. // ------------------------------------------------------------ function adicionar(texto) { const limpo = texto.trim(); if (limpo === '') return false; // campo vazio nao cria tarefa estado.tarefas.push({ id: proximoId(), texto: limpo, feita: false }); return true; } function alternar(id) { const tarefa = estado.tarefas.find((t) => t.id === id); if (tarefa) tarefa.feita = !tarefa.feita; } function excluir(id) { estado.tarefas = estado.tarefas.filter((t) => t.id !== id); } // ------------------------------------------------------------ // 4. FILTRO E BUSCA // Sao dois filtros independentes, aplicados em conjunto. Nenhum dos // dois mexe no estado: sao leituras. E por isso que a busca "volta" // sozinha quando o texto do campo e apagado. // ------------------------------------------------------------ const porSituacao = () => { if (estado.filtro === 'feitas') return estado.tarefas.filter((t) => t.feita); if (estado.filtro === 'pendentes') return estado.tarefas.filter((t) => !t.feita); return estado.tarefas; // 'todas': devolve a lista inteira }; const porBusca = (lista) => { const termo = estado.busca.trim().toLowerCase(); if (termo === '') return lista; return lista.filter((t) => t.texto.toLowerCase().includes(termo)); }; const visiveis = () => porBusca(porSituacao()); // ------------------------------------------------------------ // 5. RENDER // A tela e montada do zero a cada mudanca. Substituir tudo e mais // barato que tentar consertar o que ja estava, e nunca sobra // elemento antigo de uma tarefa que foi excluida. // ------------------------------------------------------------ function render() { const lista = visiveis(); elLista.replaceChildren( ...lista.map((tarefa) => { const li = document.createElement('li'); li.dataset.id = String(tarefa.id); const marcar = document.createElement('button'); marcar.type = 'button'; marcar.className = 'alternar'; marcar.textContent = tarefa.feita ? '[x]' : '[ ]'; const texto = document.createElement('span'); texto.className = 'texto'; texto.textContent = tarefa.texto; if (tarefa.feita) texto.classList.add('feita'); const apagar = document.createElement('button'); apagar.type = 'button'; apagar.className = 'excluir'; apagar.textContent = 'excluir'; li.append(marcar, texto, apagar); return li; }) ); const feitas = estado.tarefas.filter((t) => t.feita).length; const total = estado.tarefas.length; elContador.textContent = total === 0 ? 'nenhuma tarefa' : `${feitas} de ${total} concluidas, ${lista.length} na tela`; } // ------------------------------------------------------------ // 6. OS EVENTOS // Um listener so no <ul>, e ele descobre o alvo pelo data-id. Como // os botoes nascem dentro do render, delegacao e o caminho curto: // um listener em vez de dois por tarefa. // ------------------------------------------------------------ form.addEventListener('submit', (evento) => { evento.preventDefault(); // sem isso a pagina recarrega if (adicionar(campoTexto.value)) campoTexto.value = ''; render(); salvar(); }); campoBusca.addEventListener('input', () => { estado.busca = campoBusca.value; render(); // nao salva: a busca nao e estado duravel }); seletorFiltro.addEventListener('change', () => { estado.filtro = seletorFiltro.value; render(); }); elLista.addEventListener('click', (evento) => { const botao = evento.target.closest('button'); if (!botao) return; const li = botao.closest('li'); if (!li) return; const id = Number(li.dataset.id); if (botao.classList.contains('alternar')) alternar(id); if (botao.classList.contains('excluir')) excluir(id); render(); salvar(); }); // ------------------------------------------------------------ // 7. O ROTEIRO // dispatchEvent e o clique programatico. E assim que o programa e // testado sem alguem com o mouse na tela. // ------------------------------------------------------------ function estadoDaLista(titulo) { const linhas = [...elLista.querySelectorAll('li')].map((li) => { const marca = li.querySelector('.alternar').textContent; const texto = li.querySelector('.texto').textContent; return marca + ' ' + texto; }); console.log('\n--- ' + titulo + ' ---'); console.log(' filtro: ' + estado.filtro + ' | busca: "' + estado.busca + '"'); console.log(' no estado : ' + JSON.stringify(estado.tarefas.map((t) => t.texto))); console.log(' na tela : ' + (linhas.length ? linhas.join(' | ') : '(vazia)')); console.log(' contador : ' + elContador.textContent); } function digitar(campo, valor) { campo.value = valor; campo.dispatchEvent(new Event('input', { bubbles: true })); } function enviar() { form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); } function clicar(seletor) { const botao = elLista.querySelector(seletor); if (botao) botao.dispatchEvent(new Event('click', { bubbles: true })); } // Comeca zerado, para o programa mostrar a lista vazia. estado.tarefas = []; estado.proximoId = 1; render(); estadoDaLista('1. lista vazia'); // Campo vazio: o envio e ignorado, e o contador avisa. campoTexto.value = ' '; enviar(); console.log(' envio com campo so com espacos: nada foi criado -> ' + JSON.stringify(estado.tarefas)); campoTexto.value = 'comprar pao'; enviar(); estadoDaLista('2. depois de enviar o formulario com "comprar pao"'); digitar(campoTexto, 'estudar JavaScript'); enviar(); digitar(campoTexto, 'ligar para o suporte'); enviar(); estadoDaLista('3. tres tarefas, todas pendentes'); clicar('.alternar'); // marca a primeira linha, "comprar pao" estadoDaLista('4. apos clicar em [ ] na primeira linha'); seletorFiltro.value = 'pendentes'; seletorFiltro.dispatchEvent(new Event('change')); estadoDaLista('5. filtro pendentes: as concluidas somem da tela, nao do estado'); digitar(campoBusca, 'PARA'); // sem acento, sem maiuscula, e acha estadoDaLista('6. filtro pendentes + busca "PARA"'); seletorFiltro.value = 'todas'; seletorFiltro.dispatchEvent(new Event('change')); estadoDaLista('7. sem filtro, com a mesma busca ainda valendo'); digitar(campoBusca, ''); estadoDaLista('8. busca apagada: a lista completa volta sozinha'); digitar(campoBusca, 'pizza'); estadoDaLista('9. busca sem resultado: a tela avisa, o estado continua inteiro'); digitar(campoBusca, ''); clicar('.excluir'); // remove a primeira linha da lista estadoDaLista('10. apos excluir a primeira linha'); // A persistencia: o que foi para o localStorage e um texto. console.log('\n--- 11. o que foi guardado ---'); console.log(' localStorage["' + CHAVE + '"]: ' + window.localStorage.getItem(CHAVE)); // Recarregar a pagina e ler de volta o mesmo estado. const antes = JSON.stringify(estado.tarefas); estado = { tarefas: [], filtro: 'todas', busca: '', proximoId: 1 }; const recuperado = carregar(); estado.tarefas = recuperado.tarefas; estado.proximoId = recuperado.proximoId; render(); console.log('\n--- 12. a pagina recarregada ---'); console.log(' estado antes de salvar: ' + antes); console.log(' estado recuperado : ' + JSON.stringify(estado.tarefas.map((t) => t.texto))); console.log(' continua igual : ' + (antes === JSON.stringify(estado.tarefas))); estadoDaLista('12b. a lista depois de recuperar do localStorage'); // O contador do proximoId nao colide com id que ja existe. const idNovo = proximoId(); console.log('\n proximo id gerado: ' + idNovo + ' (o ultimo id em uso e ' + (idNovo - 1) + ')');
Saída real
--- 1. lista vazia ---
filtro: todas | busca: ""
no estado : []
na tela : (vazia)
contador : nenhuma tarefa
envio com campo so com espacos: nada foi criado -> []
--- 2. depois de enviar o formulario com "comprar pao" ---
filtro: todas | busca: ""
no estado : ["comprar pao"]
na tela : [ ] comprar pao
contador : 0 de 1 concluidas, 1 na tela
--- 3. tres tarefas, todas pendentes ---
filtro: todas | busca: ""
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [ ] comprar pao | [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 0 de 3 concluidas, 3 na tela
--- 4. apos clicar em [ ] na primeira linha ---
filtro: todas | busca: ""
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [x] comprar pao | [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 1 de 3 concluidas, 3 na tela
--- 5. filtro pendentes: as concluidas somem da tela, nao do estado ---
filtro: pendentes | busca: ""
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 1 de 3 concluidas, 2 na tela
--- 6. filtro pendentes + busca "PARA" ---
filtro: pendentes | busca: "PARA"
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [ ] ligar para o suporte
contador : 1 de 3 concluidas, 1 na tela
--- 7. sem filtro, com a mesma busca ainda valendo ---
filtro: todas | busca: "PARA"
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [ ] ligar para o suporte
contador : 1 de 3 concluidas, 1 na tela
--- 8. busca apagada: a lista completa volta sozinha ---
filtro: todas | busca: ""
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : [x] comprar pao | [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 1 de 3 concluidas, 3 na tela
--- 9. busca sem resultado: a tela avisa, o estado continua inteiro ---
filtro: todas | busca: "pizza"
no estado : ["comprar pao","estudar JavaScript","ligar para o suporte"]
na tela : (vazia)
contador : 1 de 3 concluidas, 0 na tela
--- 10. apos excluir a primeira linha ---
filtro: todas | busca: ""
no estado : ["estudar JavaScript","ligar para o suporte"]
na tela : [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 0 de 2 concluidas, 2 na tela
--- 11. o que foi guardado ---
localStorage["tarefas"]: {"tarefas":[{"id":2,"texto":"estudar JavaScript","feita":false},{"id":3,"texto":"ligar para o suporte","feita":false}],"proximoId":4}
--- 12. a pagina recarregada ---
estado antes de salvar: [{"id":2,"texto":"estudar JavaScript","feita":false},{"id":3,"texto":"ligar para o suporte","feita":false}]
estado recuperado : ["estudar JavaScript","ligar para o suporte"]
continua igual : true
--- 12b. a lista depois de recuperar do localStorage ---
filtro: todas | busca: ""
no estado : ["estudar JavaScript","ligar para o suporte"]
na tela : [ ] estudar JavaScript | [ ] ligar para o suporte
contador : 0 de 2 concluidas, 2 na tela
proximo id gerado: 4 (o ultimo id em uso e 3)