Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

Estado e formulários no React Native

Controle TextInput, valide dados e atualize uma lista com useReducer no React Native, entendendo estado sem depender de tentativa e erro.

Rodolfo Mori4 min de leitura

Estado é a memória de um componente entre renderizações. Num formulário React Native, o TextInput mostra um valor, onChangeText envia cada alteração e uma função de atualização guarda a nova versão. Nesta lição, você liga esse ciclo a uma lista de hábitos com validação e mensagens que a pessoa consegue perceber.

Continue no projeto das lições anteriores. Se props e componentes ainda estão misturados, revise componentes e estilos antes de adicionar memória à tela.

A comanda que acompanha o pedido

Imagine a comanda de uma lanchonete. Conforme alguém pede, troca ou remove um item, a comanda registra a situação atual. A cozinha não tenta adivinhar o que foi dito cinco minutos atrás; ela trabalha com o registro mais recente.

No mapa técnico, a comanda é o state, cada pedido de mudança é uma ação e a tela renderizada é a leitura atual da comanda. useState resolve memórias independentes; useReducer centraliza mudanças relacionadas numa função que recebe estado e ação e devolve o próximo estado.

O limite é importante: estado React não é armazenamento permanente. Se o app for encerrado, a memória pode desaparecer. Tecnicamente, o state vive na árvore de componentes; persistência no aparelho será adicionada na lição de APIs e armazenamento.

No guia de React Native, essa memória aparece entre a lógica React e os controles nativos. Essa posição explica por que useState não escreve diretamente no campo: ele solicita outra renderização, e então o novo value chega ao TextInput. Quando esse ciclo está claro, você investiga qual evento produziu qual próxima versão dos dados.

Controle o TextInput pelos dois lados

Um campo controlado recebe value do estado e informa mudanças por onChangeText. Crie src/components/habit-form.tsx:

tsx
import { useState } from 'react';
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';

type HabitFormProps = {
  onSubmit: (title: string) => void;
};

export function HabitForm({ onSubmit }: HabitFormProps) {
  const [title, setTitle] = useState('');
  const [error, setError] = useState('');

  function handleSubmit() {
    const normalizedTitle = title.trim();
    if (!normalizedTitle) {
      setError('Digite um hábito antes de adicionar.');
      return;
    }

    onSubmit(normalizedTitle);
    setTitle('');
    setError('');
  }

  return (
    <View style={styles.form}>
      <TextInput
        accessibilityLabel="Nome do novo hábito"
        onChangeText={setTitle}
        onSubmitEditing={handleSubmit}
        placeholder="Ex.: caminhar por 15 minutos"
        returnKeyType="done"
        value={title}
      />
      {error ? <Text accessibilityLiveRegion="polite">{error}</Text> : null}
      <Button onPress={handleSubmit} title="Adicionar hábito" />
    </View>
  );
}

Quando a pessoa digita Ler, o componente não lê o DOM como faria uma página web. O TextInput chama onChangeText('Ler'), setTitle agenda a atualização e a próxima renderização recebe value="Ler". Essa volta completa é o que torna o campo controlado.

Complete um estilo simples:

tsx
const styles = StyleSheet.create({
  form: {
    backgroundColor: '#ffffff',
    borderRadius: 14,
    gap: 10,
    padding: 16,
  },
});

Em um app final, dê também estilo ao campo e use uma área de pressão alinhada à identidade visual. O Button nativo é útil aqui porque mantém a prática focada no fluxo de dados.

Modele ações antes de espalhar alterações

Crie src/lib/habits.ts. O estado é uma lista e cada ação carrega apenas os dados necessários:

ts
import type { Habit } from '@/types/habit';

export type HabitAction =
  | { type: 'added'; habit: Habit }
  | { type: 'toggled'; id: string }
  | { type: 'loaded'; habits: Habit[] };

export function habitsReducer(state: Habit[], action: HabitAction): Habit[] {
  switch (action.type) {
    case 'added':
      return [action.habit, ...state];
    case 'toggled':
      return state.map((habit) =>
        habit.id === action.id ? { ...habit, done: !habit.done } : habit,
      );
    case 'loaded':
      return action.habits;
  }
}

O termo técnico para HabitAction é discriminated union: type discrimina qual formato está presente. Dentro de case 'toggled', TypeScript sabe que há um id; dentro de added, sabe que há um habit.

O reducer não altera o array recebido. map, spread e o novo array preservam imutabilidade, que aqui significa criar referências novas para o trecho que mudou. Assim React consegue perceber a atualização e o histórico lógico não é modificado pelas costas.

Ligue formulário e reducer na tela

Na tela inicial, inicialize a lista e transforme callbacks em ações:

tsx
import { useReducer } from 'react';

import { HabitForm } from '@/components/habit-form';
import { habitsReducer } from '@/lib/habits';

export default function HomeScreen() {
  const [habits, dispatch] = useReducer(habitsReducer, []);

  function addHabit(title: string) {
    dispatch({
      type: 'added',
      habit: { id: String(Date.now()), title, done: false },
    });
  }

  return (
    <>
      <HabitForm onSubmit={addHabit} />
      {/* a lista usará habits */}
    </>
  );
}

dispatch não altera imediatamente a constante habits daquela execução. Ele solicita outra renderização com o estado resultante. Por isso, imprimir habits.length logo depois de dispatch pode mostrar o valor anterior. A função ainda enxerga o snapshot da renderização em que foi criada.

Esse snapshot mantém todas as leituras daquela execução coerentes. Se duas ações dependem do valor anterior, descreva a transição no reducer em vez de guardar uma variável mutável fora do React. Estado externo pode mudar, mas não agenda renderização nem respeita a vida do componente; o sintoma costuma ser uma tela atrasada em relação ao dado.

Para ids locais, Date.now() atende a esta prática, mas pode colidir em eventos muito próximos. Num produto, prefira o id vindo do servidor ou uma estratégia de UUID adequada ao runtime.

Não use estado para uma informação calculável

Quantidade concluída depende apenas de habits; não precisa de outro useState. Calcule durante a renderização:

tsx
const completed = habits.filter((habit) => habit.done).length;

<Text>
  {completed} de {habits.length} concluídos
</Text>;

Guardar também completed criaria duas comandas para o mesmo pedido. Se uma ação atualizasse a lista e esquecesse o contador, a interface ficaria inconsistente. Tecnicamente, valores derivados devem ser calculados da fonte de verdade, a menos que exista uma razão medida para memorizar o cálculo.

Abra espaço para o teclado sem adivinhar plataforma

O teclado virtual pode cobrir o formulário. Envolva a tela e escolha o comportamento pela plataforma:

tsx
import { KeyboardAvoidingView, Platform } from 'react-native';

<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  style={{ flex: 1 }}
>
  {/* formulário e lista */}
</KeyboardAvoidingView>;

KeyboardAvoidingView ajusta altura, posição ou padding em resposta ao teclado. O resultado varia com layout e plataforma; teste em telas pequenas e com fonte aumentada. O componente não elimina a necessidade de rolagem quando o conteúdo é maior que a área disponível.

Reproduza o caso vazio e trate como regra

Sem trim, um título composto por espaços entra na lista. Isole a normalização para que ela também possa ser testada:

ts
export function normalizeHabitTitle(value: string): string {
  const title = value.trim();
  if (!title) throw new Error('O título do hábito está vazio.');
  return title;
}

O teste reproduz o caminho inválido e o válido:

ts
test('recusa um título vazio', () => {
  expect(() => normalizeHabitTitle('   ')).toThrow(
    'O título do hábito está vazio.',
  );
});

test('remove espaços externos', () => {
  expect(normalizeHabitTitle('  Beber água  ')).toBe('Beber água');
});

No projeto verificado, npm test -- --ci registrou:

PASS __tests__/habits-test.ts Tests: 2 passed, 2 total

No formulário desta lição, preferimos mostrar a mensagem sem lançar exceção, porque entrada vazia é uma situação prevista da interface. A função que lança serve para provar e proteger uma fronteira de domínio. Erro esperado deve virar feedback; erro inesperado deve ser investigado.

Missão: faça a comanda corrigir um item

Acrescente uma ação renamed com id e title. No reducer, devolva uma cópia do hábito correspondente, sem mutar o objeto original. Escreva o teste antes de ligar a uma interface de edição.

text
Entrada: [{ id: 'agua', title: 'Água', done: false }]
Ação: { type: 'renamed', id: 'agua', title: 'Beber 2 copos' }
Saída esperada: [{ id: 'agua', title: 'Beber 2 copos', done: false }]

Confira três coisas: o título mudou, done foi preservado e o array de saída não é a mesma referência do array de entrada. Na próxima lição, esses hábitos ganham uma lista virtualizada e cada item pode abrir sua própria rota em listas e navegação.

  • estado react native
  • formulario react native
  • textinput
  • usereducer
  • input controlado

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, Expo SDK 57.0.15, React Native 0.86.2, React 19.2.3, TypeScript 6.0.3 e Jest 29.7.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. React — State: A Component’s Memory — react.dev
  2. React — Extracting State Logic into a Reducer — react.dev
  3. React Native — TextInput — reactnative.dev
  4. React Native — KeyboardAvoidingView — reactnative.dev

Continue por aqui