App mobile com React Native, Expo e TypeScript
Crie um app mobile completo de hábitos com React Native, Expo SDK 57, TypeScript, Expo Router, AsyncStorage, API e testes executáveis.
Neste tutorial, você vai construir o Passo a Passo, um app mobile de hábitos com formulário, lista, conclusão, rota de detalhe, persistência local, dica carregada de uma API e testes. A base usa Expo SDK 57, React Native 0.86.2 e TypeScript; ao final, o projeto passa em lint, checagem de tipos, seis testes e exportação para Android, iOS e web.
A proposta é aprender as conexões, não apenas chegar a uma tela parecida com a imagem de alguém. Cada etapa entrega algo observável, depois nomeia o mecanismo técnico e volta ao código. Se uma peça ainda parecer nova, os guias de React Native e Expo oferecem o mapa mais amplo.
Antes de começar, defina o resultado que você conseguirá conferir: cadastrar “Beber água”, marcar o cartão, abrir o detalhe, fechar o app sem perder a lista e continuar usando a tela quando a API de dicas falhar. Essa sequência atravessa as principais fronteiras de um app pequeno e evita um tutorial composto por arquivos que nunca trabalham juntos. Cada etapa abaixo preserva o que já funcionava e acrescenta uma responsabilidade observável.
O app como uma pequena linha de montagem
Imagine uma oficina que recebe um pedido, valida a ficha, monta uma peça, guarda o estoque e entrega o produto no endereço certo. O formulário recebe o pedido; o reducer decide a próxima lista; AsyncStorage guarda uma cópia; Expo Router encontra a tela de detalhe; fetch busca a dica externa; os testes inspecionam pontos da linha.
O nome técnico desse desenho é separação de responsabilidades. A interface não deveria conhecer como JSON é persistido, e o storage não deveria decidir como um cartão parece. O limite da analogia é que o app não é uma sequência rígida: rede, renderização e armazenamento são assíncronos e podem se intercalar. Tecnicamente, vamos criar fronteiras pequenas para controlar essa concorrência sem inventar uma arquitetura maior que o produto.
1. Gere a base exata do projeto
O Expo SDK 57 usa Node 22.13 ou superior. O teste editorial foi feito com Node 24.16.0. Crie a base com a versão atual do gerador e entre no diretório:
npx --yes create-expo-app@4.0.0 passo-a-passo --template default --yes --no-agents-md
cd passo-a-passoO template default selecionou Expo 57.0.15 nesta data. Se você estiver lendo em outro momento, use default@sdk-57 para reproduzir a mesma matriz ou siga a versão estável atual e ajuste todos os números em conjunto. Não instale React Native 0.87 por fora deste projeto: “mais novo” e “compatível com o SDK” são perguntas diferentes.
Instale armazenamento e ferramentas de teste com o resolvedor do Expo:
npx expo install @react-native-async-storage/async-storage
npx expo install jest jest-expo @types/jest @testing-library/react-native -- --save-devNo projeto verificado, AsyncStorage ficou em 2.2.0, a linha compatível com o SDK. AsyncStorage 3 tem outra API; copiar o latest do npm sem olhar a matriz mudaria o código desta aula. Essa é uma regra prática importante no ecossistema mobile: primeiro o SDK, depois as versões coordenadas por ele.
2. Prepare scripts e o mapa de arquivos
Mantenha as dependências geradas e acrescente estes scripts e a configuração Jest ao package.json:
{
"scripts": { "start": "expo start", "lint": "expo lint",
"typecheck": "tsc --noEmit", "test": "jest --runInBand" },
"jest": { "preset": "jest-expo", "setupFiles": ["<rootDir>/jest.setup.js"] }
}O preset prepara transformação e mocks adequados ao Expo. --runInBand executa as suítes em um processo, escolha previsível para este projeto pequeno; num CI maior, meça antes de abrir mão do paralelismo. O typecheck não executa o app, e Jest não substitui o compilador: as duas verificações cobrem riscos distintos.
Remova ou arquive apenas o conteúdo demonstrativo dentro de src e deixe esta estrutura. Arquivos em src/app são rotas; componentes e serviços ficam fora dela para não virarem endereços por acidente:
src/
├── app/{_layout,index,habitos/[id]}.tsx
├── components/{habit-card,habit-form}.tsx
├── hooks/use-habits.ts
├── lib/{habits,storage}.ts
├── services/daily-tip.ts
└── types/habit.ts
__tests__/
jest.setup.js3. Dê nome aos dados e às mudanças
Crie src/types/habit.ts. Habit descreve um registro; HabitAction enumera as mensagens aceitas pelo reducer. Isso é uma union discriminada: a propriedade type permite ao TypeScript descobrir quais outros campos existem em cada caso.
export type Habit = { id: string; title: string; done: boolean; createdAt: string };
export type HabitAction =
| { type: 'loaded'; habits: Habit[] }
| { type: 'added'; title: string; id: string; now: string }
| { type: 'toggled'; id: string };O id textual liga lista, callback e rota. createdAt usa ISO 8601 porque JSON preserva string com previsibilidade; a tela poderia convertê-la para Date apenas na borda de apresentação. O tipo não valida dados externos. Ele só confere o código que consegue enxergar, por isso criaremos uma guarda para o storage.
Esse contrato também funciona como vocabulário da equipe. Quando alguém diz “hábito carregado”, existe uma estrutura comum; quando diz “ação added”, existe um conjunto exato de campos. Nomes coerentes não eliminam conversa, mas diminuem a chance de uma tela chamar name, outra chamar label e o storage salvar title para a mesma ideia. TypeScript transforma essa decisão compartilhada em feedback no editor.
4. Centralize a regra num reducer puro
Crie src/lib/habits.ts. O reducer recebe estado e ação e devolve outro estado, sem alterar o array original. Pense nele como o operador da oficina: só aceita fichas conhecidas e produz um novo registro do estoque.
import type { Habit, HabitAction } from '@/types/habit';
export function habitReducer(state: Habit[], action: HabitAction): Habit[] {
switch (action.type) {
case 'loaded': return action.habits;
case 'added': {
const title = action.title.trim();
if (!title) return state;
return [...state, { id: action.id, title, done: false, createdAt: action.now }];
}
case 'toggled': return state.map((habit) =>
habit.id === action.id ? { ...habit, done: !habit.done } : habit);
}
}
export function isHabit(value: unknown): value is Habit {
if (!value || typeof value !== 'object') return false;
const habit = value as Record<string, unknown>;
return typeof habit.id === 'string' && typeof habit.title === 'string' && typeof habit.done === 'boolean' && typeof habit.createdAt === 'string';
}trim impede um hábito só de espaços. A ação vazia devolve a mesma referência, sinal de que nada mudou. toggled copia apenas o registro correspondente. Essa imutabilidade não é uma regra moral: ela fornece ao React referências coerentes para perceber mudanças e evita modificar o estado antigo pelas costas.
Antes de abrir a interface, prove essas três regras em __tests__/habits-test.ts:
import { habitReducer } from '@/lib/habits';
import type { Habit } from '@/types/habit';
const water: Habit = { id: 'water', title: 'Beber água', done: false, createdAt: '2026-08-22T12:00:00.000Z' };
test('adiciona, ignora título vazio e alterna um hábito', () => {
const added = habitReducer([], { type: 'added', id: water.id, title: ` ${water.title} `, now: water.createdAt });
expect(added).toEqual([water]);
expect(habitReducer(added, { type: 'added', id: 'x', title: ' ', now: water.createdAt })).toBe(added);
expect(habitReducer(added, { type: 'toggled', id: 'water' })[0].done).toBe(true);
});Essa prova não depende de emulador porque o reducer é JavaScript puro. É uma vantagem concreta da fronteira: regra de negócio isolada custa pouco para testar e não precisa fingir toque, storage ou navegação.
5. Guarde uma cópia local validada
Crie src/lib/storage.ts. AsyncStorage guarda strings; JSON.stringify serializa a lista e JSON.parse recupera um valor desconhecido. A guarda isHabit confirma cada item antes de devolvê-lo ao app.
import AsyncStorage from '@react-native-async-storage/async-storage'; import { isHabit } from '@/lib/habits'; import type { Habit } from '@/types/habit';
const STORAGE_KEY = '@passo-a-passo/habits';
export async function loadHabits(): Promise<Habit[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every(isHabit)) throw new Error('Os hábitos salvos têm formato inválido');
return parsed;
}
export async function saveHabits(habits: Habit[]): Promise<void> { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(habits)); }A chave com namespace reduz colisão com outras partes do app. Num produto com evolução de schema, inclua uma versão e escreva migrações. AsyncStorage não é criptografado, não oferece consulta relacional e não deve receber segredos. Aqui ele resolve uma lista pequena que precisa sobreviver ao fechamento do aplicativo.
Ative o mock oficial em jest.setup.js:
jest.mock('@react-native-async-storage/async-storage',
() => require('@react-native-async-storage/async-storage/jest/async-storage-mock'));Teste sucesso e formato inválido em __tests__/storage-test.ts:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { loadHabits, saveHabits } from '@/lib/storage';
beforeEach(() => AsyncStorage.clear());
test('salva e recupera hábitos', async () => {
const habits = [{ id: 'water', title: 'Beber água', done: false, createdAt: '2026-08-22' }];
await saveHabits(habits); await expect(loadHabits()).resolves.toEqual(habits);
});
test('recusa JSON salvo com formato inválido', async () => {
await AsyncStorage.setItem('@passo-a-passo/habits', '{"title":"sem lista"}');
await expect(loadHabits()).rejects.toThrow('formato inválido');
});O segundo teste reproduz uma fronteira quebrada de propósito. A Promise rejeitar é o comportamento correto; o teste fica verde porque a falha foi prevista. JSON sintaticamente válido ainda pode ter o formato errado, distinção que uma simples anotação as Habit[] esconderia.
6. Coordene hidratação e gravação num hook
Crie src/hooks/use-habits.ts. A primeira Effect hidrata o reducer; a segunda só salva depois que essa leitura termina. Sem a trava hydrated, a lista inicial vazia poderia ser gravada antes da resposta do storage, apagando a cópia existente.
import { useEffect, useReducer, useState } from 'react';
import { habitReducer } from '@/lib/habits';
import { loadHabits, saveHabits } from '@/lib/storage';
export function useHabits() {
const [habits, dispatch] = useReducer(habitReducer, []); const [hydrated, setHydrated] = useState(false); const [error, setError] = useState('');
useEffect(() => {
let active = true;
loadHabits()
.then((saved) => { if (active) dispatch({ type: 'loaded', habits: saved }); })
.catch(() => { if (active) setError('Não foi possível ler os hábitos salvos.'); })
.finally(() => { if (active) setHydrated(true); });
return () => { active = false; };
}, []);
useEffect(() => {
if (!hydrated) return;
saveHabits(habits).catch(() => setError('Não foi possível salvar a mudança.'));
}, [habits, hydrated]);
function addHabit(title: string) { dispatch({ type: 'added', title, id: `habit-${Date.now()}`, now: new Date().toISOString() }); }
function toggleHabit(id: string) { dispatch({ type: 'toggled', id }); }
return { habits, addHabit, toggleHabit, error };
}active impede atualizar state depois que a tela desmontou durante a leitura. Ele não cancela a operação nativa; só ignora seu retorno. Date.now() atende à prática local, mas pode colidir em eventos extremos ou entre dispositivos. Quando o app sincronizar com servidor, deixe a fonte de dados definir uma identidade robusta.
7. Faça um formulário controlado
Crie src/components/habit-form.tsx. TextInput recebe value e envia texto por onChangeText; isso forma um campo controlado. O estado é a comanda atual, e cada tecla devolve uma versão para a próxima renderização.
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
type Props = { onAdd: (title: string) => void };
export function HabitForm({ onAdd }: Props) {
const [title, setTitle] = useState('');
function submit() { if (!title.trim()) return; onAdd(title); setTitle(''); }
return (
<View style={styles.row}>
<TextInput accessibilityLabel="Nome do hábito" value={title}
onChangeText={setTitle} onSubmitEditing={submit} placeholder="Ex.: beber água" placeholderTextColor="#718078" returnKeyType="done" style={styles.input} />
<Pressable accessibilityRole="button" onPress={submit} style={styles.button}>
<Text style={styles.buttonText}>Adicionar</Text></Pressable>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 10, marginTop: 20 },
input: { flex: 1, backgroundColor: '#1c251f', borderColor: '#34483b', borderWidth: 1, borderRadius: 12, color: '#fff', paddingHorizontal: 14, minHeight: 48 },
button: { minHeight: 48, justifyContent: 'center', borderRadius: 12, backgroundColor: '#9deb87', paddingHorizontal: 14 },
buttonText: { color: '#10200f', fontWeight: '800' },
});A validação também existe no reducer, porque interface não é fronteira de confiança. O formulário evita uma ação inútil e o reducer protege qualquer outra origem de ação. Em telas pequenas, teste o teclado cobrindo o campo e considere KeyboardAvoidingView; essa experiência visual não foi automatizada neste pipeline.
8. Monte um cartão com duas ações acessíveis
Crie src/components/habit-card.tsx. Marcar e abrir são ações diferentes, portanto recebem controles diferentes. O papel checkbox, o estado checked e os labels ajudam leitor de tela e também oferecem seletores públicos para os testes.
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { Habit } from '@/types/habit';
type Props = { habit: Habit; onToggle: (id: string) => void; onOpen: (id: string) => void };
export function HabitCard({ habit, onToggle, onOpen }: Props) {
return (
<View style={styles.card}>
<Pressable accessibilityLabel={`Marcar ${habit.title} como ${habit.done ? 'pendente' : 'feito'}`}
accessibilityRole="checkbox" accessibilityState={{ checked: habit.done }} onPress={() => onToggle(habit.id)} style={[styles.check, habit.done && styles.checkDone]}>
<Text>{habit.done ? '✓' : ''}</Text></Pressable>
<Text style={[styles.title, habit.done && styles.titleDone]}>{habit.title}</Text>
<Pressable accessibilityLabel={`Abrir ${habit.title}`} accessibilityRole="button" onPress={() => onOpen(habit.id)}><Text style={styles.link}>Detalhes</Text></Pressable>
</View>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 16, backgroundColor: '#1c251f', padding: 16 },
check: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderColor: '#9deb87', borderRadius: 8, borderWidth: 2 },
checkDone: { backgroundColor: '#9deb87' },
title: { flex: 1, color: '#fff', fontSize: 17 },
titleDone: { color: '#829087', textDecorationLine: 'line-through' },
link: { color: '#9deb87', fontWeight: '700' },
});O array de estilos aplica a base e, quando done é verdadeiro, a variação posterior. Isso não é uma cascata CSS: são objetos React Native compostos na ordem. A analogia é trocar a etiqueta de uma gaveta; tecnicamente, o dado decide quais objetos entram na prop durante a renderização.
Crie __tests__/habit-card-test.tsx:
import { fireEvent, render, screen } from '@testing-library/react-native';
import { HabitCard } from '@/components/habit-card';
test('mostra o hábito e envia o id ao marcar', async () => {
const onToggle = jest.fn();
await render(<HabitCard habit={{ id: 'water', title: 'Beber água', done: false, createdAt: '2026-08-22' }} onToggle={onToggle} onOpen={jest.fn()} />);
fireEvent.press(screen.getByRole('checkbox', { name: /Marcar Beber água/ }));
expect(onToggle).toHaveBeenCalledWith('water');
});O teste não procura styles.check nem chama onToggle diretamente. Ele encontra o papel que a pessoa usa, pressiona e observa a mensagem emitida. Uma refatoração visual pode ocorrer sem destruir uma prova de comportamento válido.
9. Isole a API e reproduza HTTP 503
Crie src/services/daily-tip.ts. fetch só rejeita automaticamente em falhas de rede; HTTP 503 ainda produz uma resposta. Por isso conferimos ok, lemos JSON como formato externo e validamos o campo usado.
type TipResponse = { title?: unknown };
export async function fetchDailyTip(fetcher: typeof fetch = fetch): Promise<string> {
const response = await fetcher('https://jsonplaceholder.typicode.com/todos/1');
if (!response.ok) throw new Error(`A API respondeu ${response.status}`);
const data = (await response.json()) as TipResponse;
if (typeof data.title !== 'string') throw new Error('A API devolveu um formato inesperado');
return data.title;
}Injetar fetcher é como colocar uma conexão de teste na bancada: o código de produção usa fetch, mas a suíte controla cada resposta. O limite é que esse mock não prova DNS, TLS ou disponibilidade do serviço. Ele prova apenas como nossa função reage aos contratos simulados.
Crie __tests__/daily-tip-test.ts:
import { fetchDailyTip } from '@/services/daily-tip';
test('lê o título de uma resposta válida', async () => {
const fetcher = jest.fn().mockResolvedValue({
ok: true, status: 200, json: async () => ({ title: 'Faça uma pausa curta' }),
});
await expect(fetchDailyTip(fetcher)).resolves.toBe('Faça uma pausa curta');
});
test('transforma status HTTP em erro legível', async () => {
const fetcher = jest.fn().mockResolvedValue({ ok: false, status: 503 });
await expect(fetchDailyTip(fetcher)).rejects.toThrow('A API respondeu 503');
});O endpoint público respondeu 200 com o título delectus aut autem na conferência externa. Ele serve à demonstração, não como dependência confiável de produto. A tela precisa preservar hábitos e explicar indisponibilidade mesmo se a dica nunca chegar.
10. Declare a pilha de navegação
No Expo Router atual, arquivos em src/app viram rotas. Crie src/app/_layout.tsx:
import { Stack } from 'expo-router';
export default function RootLayout() {
return <Stack><Stack.Screen name="index" options={{ title: 'Hábitos' }} />
<Stack.Screen name="habitos/[id]" options={{ title: 'Detalhe' }} /></Stack>;
}Stack mantém um histórico: abrir detalhes acrescenta uma tela; voltar remove o topo. O arquivo registra a rota, e Stack.Screen configura seu título. Não importe a tela de detalhe aqui. Para variações com tabs e route groups, avance depois pela lição dedicada de Expo Router.
11. Una formulário, lista, API e Router
Crie src/app/index.tsx. FlatList virtualiza a coleção, a chave estável vem do id, o hook entrega ações e router.push abre a rota dinâmica. A dica remota tem três mensagens observáveis: carregando, sucesso ou fallback.
import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context';
import { HabitCard } from '@/components/habit-card'; import { HabitForm } from '@/components/habit-form';
import { useHabits } from '@/hooks/use-habits'; import { fetchDailyTip } from '@/services/daily-tip';
export default function HomeScreen() {
const router = useRouter(); const { habits, addHabit, toggleHabit, error } = useHabits(); const [tip, setTip] = useState('Carregando dica...');
useEffect(() => {
let active = true;
fetchDailyTip().then((message) => { if (active) setTip(message); })
.catch(() => { if (active) setTip('Dica indisponível. Seus hábitos continuam aqui.'); });
return () => { active = false; };
}, []);
return (
<SafeAreaView style={styles.page}>
<Text style={styles.eyebrow}>PASSO A PASSO</Text><Text style={styles.title}>Hábitos de hoje</Text><Text style={styles.subtitle}>Uma ação pequena por vez.</Text>
<Text accessibilityLiveRegion="polite" style={styles.tip}>{tip}</Text>
<HabitForm onAdd={addHabit} />
{error ? <Text accessibilityRole="alert">{error}</Text> : null}
<FlatList data={habits} keyExtractor={(item) => item.id} contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.empty}>Cadastre seu primeiro hábito.</Text>}
renderItem={({ item }) => <HabitCard habit={item} onToggle={toggleHabit}
onOpen={(id) => router.push({ pathname: '/habitos/[id]', params: { id } })} />} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1, backgroundColor: '#101512', padding: 20 },
eyebrow: { color: '#9deb87', fontSize: 12, fontWeight: '700' },
title: { color: '#fff', fontSize: 30, fontWeight: '800', marginTop: 6 },
subtitle: { color: '#b8c4bb', fontSize: 16, marginTop: 6 },
tip: { color: '#dce5de', fontSize: 14, marginTop: 12 },
list: { gap: 12, paddingVertical: 20 },
empty: { color: '#b8c4bb', paddingVertical: 24, textAlign: 'center' },
});O primeiro Effect ignora respostas depois da desmontagem, mas não cancela a rede. Em uma busca que dependa de parâmetros, use AbortController quando suportado e evite corrida entre respostas. A lista local não depende da dica: isso garante que uma falha externa não bloqueie a principal tarefa do app.
12. Leia o parâmetro na tela de detalhe
Crie src/app/habitos/[id].tsx:
import { useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function HabitDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={styles.page}>
<Text style={styles.label}>IDENTIFICADOR</Text><Text selectable style={styles.id}>{id}</Text>
<Text style={styles.help}>Esta rota recebeu o parâmetro pelo endereço /habitos/[id].</Text>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1, backgroundColor: '#101512', padding: 24 },
label: { color: '#9deb87', fontSize: 12, fontWeight: '700' },
id: { color: '#fff', fontSize: 24, fontWeight: '800', marginTop: 8 },
help: { color: '#b8c4bb', fontSize: 16, lineHeight: 24, marginTop: 16 },
});O parâmetro é entrada, não autorização. Num app conectado ao backend, valide o formato, carregue o registro e deixe o servidor conferir acesso. Esta tela mostra o mecanismo de rota; a próxima evolução é buscar o hábito real pelo id e oferecer estados de carregamento, ausência e sucesso.
Para enxergar o fluxo completo, faça um ensaio manual depois que o servidor iniciar: cadastre dois hábitos, marque apenas um, abra o detalhe de cada um e volte. Em seguida recarregue o aplicativo. A lista recuperada deve manter os dois títulos e o estado marcado, enquanto a dica pode mudar ou cair no fallback. Esse roteiro separa persistência local, histórico de navegação e dependência remota; se algo falhar, você já sabe qual fronteira observar primeiro.
O ensaio visual precisa acontecer em tela estreita e com fonte do sistema ampliada. Verifique se o botão continua alcançável com teclado aberto, se textos longos não empurram “Detalhes” para fora e se os labels fazem sentido sem olhar a tela. Essas verificações não cabem honestamente no Jest usado aqui. Elas transformam o bundle tecnicamente válido em uma experiência que realmente pode ser usada.
13. Rode as quatro camadas de verificação
Primeiro confira lint e tipos:
npm run lint
npm run typecheckAmbos terminaram sem diagnóstico no projeto editorial. Em seguida, rode as suítes:
npm test -- --ciO resultado real, depois de integrar a API à tela, foi:
Confira também a saúde da matriz instalada:
npx expo install --check
npx expo-doctor@1.20.2A saída observada foi Dependencies are up to date e 21/21 checks passed. No issues detected!. Expo Doctor não prova a experiência da tela, mas encontra dependências incompatíveis e configurações conhecidas antes de uma compilação mais cara.
Por fim, peça bundles para todas as plataformas:
npx expo export --platform all --output-dir dist-proofO Metro empacotou 1.245 módulos Android, 1.115 iOS e 786 web. O Router listou / e /habitos/[id], e a execução terminou com:
Essa prova confirma resolução de módulos, transformação e exportação; não houve execução visual em aparelho ou simulador. Antes de publicar, teste teclado, áreas seguras, gesto de voltar, modo offline, fonte ampliada, leitores de tela e desempenho numa build release. O tutorial não troca honestidade por uma marca codeTested ampla demais.
14. Entenda o erro antes de procurar outra biblioteca
Se o teste de storage receber {"title":"sem lista"}, JSON.parse funciona, mas a guarda rejeita o formato com “Os hábitos salvos têm formato inválido”. Se a API responder 503, fetchDailyTip lança “A API respondeu 503”. São dois erros reproduzidos, com origens diferentes e tratamentos diferentes.
Na oficina, uma ficha local antiga e uma entrega remota recusada chegam ao mesmo balcão, mas não têm a mesma causa. Tecnicamente, o hook converte falha local em alerta sem destruir o estado em memória, enquanto a dica remota converte falha em texto de fallback. Não use um catch vazio: ele faz o app parecer parado e apaga a informação necessária para suporte.
Missão final: transforme a prova em produto seu
Implemente remoção com confirmação, edite títulos e troque o id temporal por UUID ou id do servidor. Adicione um teste de reducer para cada ação e faça a rota de detalhe buscar o hábito existente. Depois execute a matriz abaixo, sempre registrando aparelho e versão:
[ ] entrada vazia não cria item; fechar e abrir preserva hábitos
[ ] modo avião mantém a lista e mostra fallback; id inexistente tem estado próprio
[ ] leitor de tela anuncia checkbox e alertas
[ ] lint, typecheck, 6+ testes e export continuam verdesO projeto está pronto para a próxima fase quando você consegue explicar por que cada fronteira existe e reproduzir suas provas. Para aprofundar a disciplina de medição antes do release, siga para testes e performance no React Native com Expo. Para decidir binário e entrega às lojas, consulte EAS Build, Update e Submit.
Perguntas frequentes
Preciso de Android Studio ou Xcode para acompanhar o tutorial?
Por que o tutorial usa React Native 0.86 se existe uma versão mais nova?
AsyncStorage é um banco de dados seguro?
O app deste tutorial foi realmente testado?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, Expo SDK 57.0.15, Expo CLI 57.0.17, React Native 0.86.2, React 19.2.3, TypeScript 6.0.3, AsyncStorage 2.2.0, Jest 29.7.0 e Testing Library 14.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Expo — documentação da versão atual — docs.expo.dev
- Expo — Create a project — docs.expo.dev
- Expo Router — Introduction — docs.expo.dev
- React Native — FlatList — reactnative.dev
- Expo — Unit testing with Jest — docs.expo.dev


