Dia 13 — Formulário que grava e revê
useReducer e estado que cresce
useReducer e estado que cresce
useState funciona enquanto o estado é uma coisa só. No primeiro
setEstado(valor) o estado vira o valor, no segundo vira a função, e o
useState decide entre os dois pela forma. O estado cresce até o dia em que
ele passa a ser um objeto com nome, e o set precisa decidir qual campo muda —
e aí o useState fica com a forma set(estado) => ({ ...estado, nome })
escrito dentro do componente, repetido em cada campo.
useReducer tira essa decisão do componente. O estado é uma coisa só, o set
vira dispatch, e uma função separada — o reducer — recebe o estado atual e
a ação, e devolve o próximo estado:
function reducer(estado, acao) { switch (acao.tipo) { case 'alterar': return { ...estado, [acao.campo]: acao.valor }; case 'resetar': return estadoInicial; default: return estado; } } const [estado, despachar] = useReducer(reducer, estadoInicial);
O exemplo desta página tem um formulário de três campos — nome, email e
idade — e o estado inicial sai como { nome: '', email: '', idade: '' }. As
três alterações seguintes usam o mesmo caminho: uma ação alterar com
campo e valor. Nenhum set foi escrito no componente, e nenhum campo tem
código próprio. É a economia que o reducer promete.
A ação é um objeto com tipo e os dados da mudança, e nomear o tipo como
verbo no passado ou no infinitivo é o que torna o histórico legível. dispatch
não altera nada: ele entrega a ação ao reducer. É a distinção que elimina a
classe de bug em que dois set competem pelo mesmo estado no mesmo toque.
A primeira alteração já mostra o mecanismo inteiro: o estado mudou? true, e o
despachar devolve o estado anterior e o novo lado a lado. Alterar o e-mail
depois devolve "o nome continua: ana" — o campo que não estava na ação não foi
tocado, porque o Object.assign copia o estado inteiro e sobrescreve só a chave
indicada.
o reducer é puro
Puro significa: o reducer devolve sempre o mesmo resultado para a mesma
entrada, e não mexe em nada de fora. Ele não chama fetch, não grava no
armazenamento e não usa Date.now(). Isso parece restritivo até o dia em que
um bug exige voltar dois passos atrás: com reducer puro, dá para rodar a mesma
ação sobre o mesmo estado e chegar ao mesmo lugar, e o defeito se reproduz.
O exemplo prova a pureza com a mesma entrada duas vezes: a primeira chamada
devolve { nome: 'bruno', email: '[email protected]', idade: '' } e a segunda
devolve exatamente o mesmo objeto. A entrada não foi alterada no caminho — o
nome continua "bruno" e a idade continua vazia. Um reducer que mutasse o
estado devolveria algo diferente na segunda vez.
O default devolvendo estado é obrigatório por um motivo prático: ação
desconhecida não pode quebrar a tela, e o reducer que devolve undefined no
default zera o estado inteiro. É o defeito mais caro de useReducer, porque
a tela fica vazia e a causa está longe do lugar onde o erro aparece.
Os dois console do default testam isso com duas ações diferentes: uma ação
de tipo "inexistente" e uma ação sem tipo nenhum. As duas devolvem o estado
inicial intacto. É a mesma proteção do parágrafo, medida: uma ação nova — de uma
biblioteca, de um dispatch antigo que sobrou — não derruba o formulário.
Estado inicial vem no segundo argumento do useReducer e o reducer é quem
devolve o estado vazio no resetar — o estadoInicial precisa existir como
constante fora, para que a linha do reset seja return estadoInicial e não
return {}.
O ciclo do reset no exemplo fecha o parágrafo: depois de submeter, o estado tem
os três campos preenchidos mais enviado: true; depois de resetar, os campos
voltam ao vazio e enviado volta a false. E o console confirma com
"os tres campos voltaram ao vazio: true". O `Object.assign({}, estadoInicial,
{ enviado: false })` reaproveita a constante — o que faz o reset funcionar em
um case só e não em três.
A árvore fecha o argumento com a parte que o reducer não resolve sozinho: o
componente recebe o estado por prop e mostra o value dos campos a partir dele.
Nada é escrito dentro do componente, e o último console confere que o valor
que ele recebe depois do reset é a string vazia — o mesmo estado que o reducer
devolveu.
Exemplo
// `useReducer` tira do componente a decisao de qual campo muda: o // componente despacha uma ACAO e o reducer devolve o proximo estado. O // exemplo e um formulario com tres campos. const { View, Text, TextInput, useReducer, StyleSheet } = require('react-native'); // (1) o estado inicial e o reducer: duas funcoes puras, fora do componente. const estadoInicial = { nome: '', email: '', idade: '' }; function reducer(estado, acao) { switch (acao.tipo) { case 'alterar': // um unico caminho para os tres campos return Object.assign({}, estado, { [acao.campo]: acao.valor }); case 'submeter': return Object.assign({}, estado, { enviado: true }); case 'resetar': return Object.assign({}, estadoInicial, { enviado: false }); default: return estado; // sem `default`, uma acao nova zera o estado } } // (2) `dispatch` nao altera nada: entrega a acao ao reducer. function criarDespachador() { let estado = estadoInicial; return { despachar(acao) { const anterior = estado; estado = reducer(estado, acao); return { anterior, agora: estado, mudou: anterior !== estado }; }, ler() { return estado; }, }; } const loja = criarDespachador(); console.log('estado inicial:', loja.ler()); let passo = loja.despachar({ tipo: 'alterar', campo: 'nome', valor: 'ana' }); console.log('\nalterar nome ->', passo.agora.nome, '| o estado mudou?', passo.mudou); passo = loja.despachar({ tipo: 'alterar', campo: 'email', valor: '[email protected]' }); console.log('alterar e-mail ->', passo.agora.email, '| o nome continua:', passo.agora.nome); passo = loja.despachar({ tipo: 'alterar', campo: 'idade', valor: '34' }); console.log('alterar idade ->', passo.agora.idade); console.log('estado com os tres campos:', loja.ler()); // (3) o reducer e puro: mesma entrada, mesma saida. const entrada = { nome: 'bruno', email: '', idade: '' }; const acao = { tipo: 'alterar', campo: 'email', valor: '[email protected]' }; console.log('\nprimeira vez:', reducer(entrada, acao)); console.log('segunda vez: ', reducer(entrada, acao)); console.log('mesma saida nas duas: o reducer nao mexe em nada de fora'); // (4) o `default` devolve o estado: acao desconhecida nao quebra a tela. console.log('\nacao desconhecida:', reducer(estadoInicial, { tipo: 'inexistente' })); console.log('acao sem tipo:', reducer(estadoInicial, {})); // (5) resetar devolve o estado inicial da constante, nao um objeto vazio. const depoisDeEnviar = loja.despachar({ tipo: 'submeter' }); console.log('\ndepois de submeter:', depoisDeEnviar.agora); const depoisDeLimpar = loja.despachar({ tipo: 'resetar' }); console.log('depois de resetar:', depoisDeLimpar.agora); console.log('os tres campos voltaram ao vazio:', depoisDeLimpar.agora.nome === '' && depoisDeLimpar.agora.email === ''); // (6) a arvore: o componente despacha, e nao escreve o estado. const estilos = StyleSheet.create({ campo: { borderWidth: 1, borderColor: '#d0d7de', padding: 8 } }); function FormularioComReducer(props) { return ( <View> <TextInput style={estilos.campo} value={props.estado.nome} placeholder="nome" /> <TextInput style={estilos.campo} value={props.estado.email} placeholder="e-mail" /> <Text>{props.estado.enviado ? 'enviado' : 'nao enviado'}</Text> </View> ); } const arvore = FormularioComReducer({ estado: loja.ler() }); console.log('\ntipo da arvore:', arvore.type, '| filhos:', arvore.props.children.length); console.log('o valor do primeiro campo:', arvore.props.children[0].props.value); console.log('o estado depois de resetar e o que o componente recebe:', FormularioComReducer({ estado: loja.ler() }).props.children[0].props.value === '');
Saída real
estado inicial: { nome: '', email: '', idade: '' }
alterar nome -> ana | o estado mudou? true
alterar e-mail -> [email protected] | o nome continua: ana
alterar idade -> 34
estado com os tres campos: { nome: 'ana', email: '[email protected]', idade: '34' }
primeira vez: { nome: 'bruno', email: '[email protected]', idade: '' }
segunda vez: { nome: 'bruno', email: '[email protected]', idade: '' }
mesma saida nas duas: o reducer nao mexe em nada de fora
acao desconhecida: { nome: '', email: '', idade: '' }
acao sem tipo: { nome: '', email: '', idade: '' }
depois de submeter: { nome: 'ana', email: '[email protected]', idade: '34', enviado: true }
depois de resetar: { nome: '', email: '', idade: '', enviado: false }
os tres campos voltaram ao vazio: true
tipo da arvore: View | filhos: 3
o valor do primeiro campo:
o estado depois de resetar e o que o componente recebe: true
Revisar o que foi preenchido
Revisar o que foi preenchido
Revisar é mostrar o que foi gravado, e a tela de revisão é a que mais denuncia
inconsistência de estado — porque é ela que junta o que veio de várias
operações: o que foi criado agora, o que foi carregado do servidor, e o que
ficou de uma edição anterior.
O estado vazio é o primeiro caso, e tem uma regra que evita metade das telas
mortas: lista vazia é um estado, não um erro. São quatro estados distintos e
a tela precisa dos quatro — carregando, vazio, com erro e com dado. A tela que
só sabe desenhar o estado com dado passa a lista vazia como "carregando para
sempre", e o usuário fica sem saber se o app quebrou ou se ele não tem
registro.
O exemplo desta página escreve os quatro estados num array e a função telaDo
escolhe entre eles com três condições. As quatro linhas de saída são a prova de
que são quatro e não dois: carregando sem dado, pronto sem registro, pronto com
registro e com erro. O erro aparece mesmo com dado presente — o que é a parte
que costuma faltar, porque a condição de erro costuma ser testada só quando a
lista está vazia.
Remover tem dois passos, e o passo que se esquece é o que produz a lista
incoerente: confirmar, depois remover. Alert.alert com dois botões é o
componente que confirma — e a alternativa sem alerta é uma tela de confirmação,
que em aplicativo sério é o caminho melhor, porque dá para mostrar o que está
sendo removido.
A atualização da lista depois de remover precisa ser feita pelo id, não pela
posição. filtrar por id é o comportamento certo; remover pelo índice quebra
quando a lista já mudou entre o clique e a confirmação.
O exemplo remove pelo id e o resultado traz as três peças: o nome do que foi
removido, a contagem antes e depois, e os id que sobraram — [ 'p41', 'p43' ].
E o caso do registro inexistente sai com removido: null e as duas contagens
iguais: a função não falhou, não encontrou alvo. É a diferença entre "não existe
mais" e "não deu para remover", e uma lista reativa precisa respeitar as duas.
editar e recarregar
Editar é carregar o registro, abrir o formulário com os valores no lugar e
gravar por cima. O detalhe que evita defeito é comparar antes de gravar: sem
diferença, não há por que chamar o servidor, e a tela não precisa recarregar
tudo. Com diferença, o id é o que faz o registro ser o mesmo e não um
novo — gravar com id ausente é o que cria duplicata em cada edição.
O exemplo grava pelo id e compara as duas respostas: gravado: true para o
registro existente, gravado: false com o motivo "registro nao existe" para o
p99. A segunda resposta é a que impede a duplicata — sem id, o servidor
receberia um registro novo em vez de uma edição.
A comparação antes de gravar é uma função sobre os campos, e o exemplo mede os
dois lados: sem diferença, false; com o total mudado, true. O `campo !==
'id' dentro do some é o detalhe — o id` é o mesmo registro, não uma
alteração. Sem essa exclusão, toda edição seria detectada como mudança.
refetch é a saída de emergência, e é melhor que a tela mostrar dado velho
para sempre. Mas ele tem custo: recarregar a lista inteira perde a posição da
rolagem e o filtro digitado. Por isso ele é botão de "atualizar" e não
comportamento automático.
O exemplo mede esse custo em um número: p41 estava na posição 0 e passa para
a posição 1 depois do refetch, porque a resposta nova trouxe outro registro
antes dele. A rolagem estava em { posicao: 12, filtro: 'ana' } e o console
completa que ela e o filtro só existem se forem guardados a parte. A posição
muda sozinha; o resto precisa ser salvo por quem recarrega.
o estado que sobrevive à tela
Quando a tela sai e volta, o estado é recriado do zero, e é por isso que o
refetch existe. Se o dado precisa sobreviver, ele vai para um lugar acima da
tela — o cache do serviço ou um contexto. A alternativa mais barata, e a que
resolve o caso comum de "voltei e perdi o que digitei", é guardar o formulário
num estado só na navegação, passando os valores por parâmetro de rota em vez do
registro inteiro: o valor é pequeno, é estável e não expira.
A árvore do exemplo trata o estado vazio como um ramo da própria tela, e o
retorno antecipado resolve: com dados são 2 filhos, e sem nenhum registro a
tela devolve um Text cujo conteúdo é "nenhum registro ainda". O return antes
do map é o que impede a lista de tentar desenhar itens inexistentes — e o que
permite que o texto do estado vazio seja escrito uma vez só, no lugar onde ele
pertence.
Exemplo
// Tela de revisao: os quatro estados (carregando, vazio, erro, com // dado), remocao pelo `id`, e o que `refetch` custa quando a lista e // recarregada. O exemplo opera sobre a lista em memoria. const { View, Text, Pressable, StyleSheet } = require('react-native'); // (1) os quatro estados que a tela precisa saber distinguir. const ESTADOS = ['carregando', 'vazio', 'erro', 'com dado']; function telaDo(estado, temDados) { if (estado.carregando && !temDados) return ESTADOS[0]; if (estado.erro) return ESTADOS[2]; if (!temDados) return ESTADOS[1]; return ESTADOS[3]; } console.log('estados possiveis:', ESTADOS); console.log('carregando sem dado: ', telaDo({ carregando: true, erro: null }, false)); console.log('pronto sem registro: ', telaDo({ carregando: false, erro: null }, false)); console.log('pronto com registro: ', telaDo({ carregando: false, erro: null }, true)); console.log('com erro: ', telaDo({ carregando: false, erro: 'nao leu' }, true)); console.log('lista vazia e um estado, nao um erro'); // (2) a lista e as operacoes sobre ela. let pedidos = [ { id: 'p41', cliente: 'ana', total: 90 }, { id: 'p42', cliente: 'bruno', total: 45 }, { id: 'p43', cliente: 'carla', total: 120 }, ]; function remover(id) { const alvo = pedidos.find((p) => p.id === id); const antes = pedidos.length; pedidos = pedidos.filter((p) => p.id !== id); // remove pelo `id` return { removido: alvo ? alvo.cliente : null, antes, depois: pedidos.length }; } function editar(id, mudanca) { const existe = pedidos.some((p) => p.id === id); if (!existe) return { gravado: false, motivo: 'registro nao existe' }; pedidos = pedidos.map((p) => (p.id === id ? Object.assign({}, p, mudanca) : p)); return { gravado: true, motivo: null }; } console.log('\nconfirmar antes de remover: o id vem do item, nao da posicao'); console.log('confirmacao:', 'remover p42?', '| o texto da confirmacao traz o cliente:', pedidos.find((p) => p.id === 'p42').cliente); const r1 = remover('p42'); console.log('depois de remover:', r1, '| ids:', pedidos.map((p) => p.id)); console.log('remover o que nao existe:', remover('p99')); console.log('\neditar: o `id` e o que garante que e o mesmo registro'); console.log(editar('p41', { total: 120 })); console.log(editar('p99', { total: 10 })); console.log('sem `id` na gravacao, cada edicao criaria um registro novo'); // (3) comparar antes de gravar: sem diferenca, nao ha por que chamar o servidor. function mudou(antes, depois) { return Object.keys(antes).some((campo) => campo !== 'id' && antes[campo] !== depois[campo]); } const original = pedidos[0]; console.log('\ncomparando antes de gravar:'); console.log('sem diferenca:', mudou(original, Object.assign({}, original))); console.log('com diferenca:', mudou(original, Object.assign({}, original, { total: 300 }))); // (4) o que `refetch` custa. function comPosicao(lista, alvo) { return lista.findIndex((p) => p.id === alvo); } const posicaoAntes = comPosicao(pedidos, 'p41'); const rolagem = { posicao: 12, filtro: 'ana' }; console.log('\nantes do refetch: posicao na lista', posicaoAntes, '| rolagem', JSON.stringify(rolagem)); const recarregada = [ { id: 'p40', cliente: 'dani', total: 10 }, { id: 'p41', cliente: 'ana', total: 90 }, { id: 'p43', cliente: 'carla', total: 120 }, ]; console.log('depois do refetch: posicao na lista', comPosicao(recarregada, 'p41')); console.log('a posicao mudou, e a rolagem e o filtro so existem se forem guardados a parte'); console.log('`refetch` e o botao de atualizar, nao comportamento automatico'); // (5) a arvore da tela de revisao, com o estado vazio junto. const estilos = StyleSheet.create({ linha: { padding: 12 }, vazio: { padding: 24, textAlign: 'center' } }); function TelaRevisao(props) { if (props.itens.length === 0) { return <Text style={estilos.vazio}>nenhum registro ainda</Text>; } return ( <View> {props.itens.map((item) => ( <View key={item.id}> <Text style={estilos.linha}>{item.cliente}</Text> <Pressable onPress={() => props.aoRemover(item.id)}> <Text>remover</Text> </Pressable> </View> ))} </View> ); } const comDados = TelaRevisao({ itens: pedidos, aoRemover: () => {} }); console.log('\ncom dados, filhos:', comDados.props.children.length); const semDados = TelaRevisao({ itens: [], aoRemover: () => {} }); console.log('vazio, filhos:', semDados.props.children.length, '| texto:', semDados.props.children); console.log('o estado vazio e um ramo da propria tela, com o que mostrar nele');
Saída real
estados possiveis: [ 'carregando', 'vazio', 'erro', 'com dado' ]
carregando sem dado: carregando
pronto sem registro: vazio
pronto com registro: com dado
com erro: erro
lista vazia e um estado, nao um erro
confirmar antes de remover: o id vem do item, nao da posicao
confirmacao: remover p42? | o texto da confirmacao traz o cliente: bruno
depois de remover: { removido: 'bruno', antes: 3, depois: 2 } | ids: [ 'p41', 'p43' ]
remover o que nao existe: { removido: null, antes: 2, depois: 2 }
editar: o `id` e o que garante que e o mesmo registro
{ gravado: true, motivo: null }
{ gravado: false, motivo: 'registro nao existe' }
sem `id` na gravacao, cada edicao criaria um registro novo
comparando antes de gravar:
sem diferenca: false
com diferenca: true
antes do refetch: posicao na lista 0 | rolagem {"posicao":12,"filtro":"ana"}
depois do refetch: posicao na lista 1
a posicao mudou, e a rolagem e o filtro so existem se forem guardados a parte
`refetch` e o botao de atualizar, nao comportamento automatico
com dados, filhos: 2
vazio, filhos: 21 | texto: nenhum registro ainda
o estado vazio e um ramo da propria tela, com o que mostrar nele