Dia 13 — Estado da aplicacao e projeto final

Informatica · Conteudo · publicado em 02/10/2026
Dia 13 de 13

Estado da aplicacao e projeto final

Aula 1

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:

  1. Evento — alguém clicou, digitou, enviou.
  2. Estado — a função de ação altera estado e nada mais.
  3. 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.

ParteO que fazAssinatura
Limpar o containerapaga os filhos sem apagar o próprio elemento`elemento.replaceChildren(...filhos: (Node \string)[]): void`
Criar um itemcria o elemento, ainda fora da teladocument.createElement(nomeDaTag: string): HTMLElement
Jogar na telainsere 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 render estava 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.
Aula 2

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ê.

ParteOnde ficaO que faz
Estadolet estado = { tarefas, filtro, busca, proximoId }a fonte única da verdade
Açõesadicionar, alternar, excluirmudam o estado e nada mais
LeituraporSituacao, porBusca, visiveiscalculam o que aparece
Renderrender()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çãoAssinaturaEfeito
adicionaradicionar(texto: string): booleancria a tarefa e devolve true; devolve false se o texto é vazio
alternaralternar(id: number): voidinverte feita da tarefa com aquele id
excluirexcluir(id: number): voidremove 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étodoAssinaturaO que faz
GravarsetItem(chave: string, valor: string): voidcria ou substitui o valor da chave
Ler`getItem(chave: string): string \null`devolve null se a chave não existe
ApagarremoveItem(chave: string): voidremove a chave
Limparclear(): voidapaga 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:// e https://exemplo.local nã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 o render lê 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 por dataset.id?
  • Filtro e busca são calculados, nunca gravados no estado?
  • O localStorage só recebe JSON.stringify, e o JSON.parse está 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çaOnde rodaAssinatura
ServidorNode.jscreateServer(puxar: (req, res) => void): Server
Ler do servidornavegador, no carregamentofetch(url: string): Promise<Response>
Gravar no servidornavegador, depois da açãofetch(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)