Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
TutorialIntermediáriocódigo testado

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.

Rodolfo Mori11 min de leitura

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:

bash
npx --yes create-expo-app@4.0.0 passo-a-passo --template default --yes --no-agents-md
cd passo-a-passo

O 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:

bash
npx expo install @react-native-async-storage/async-storage
npx expo install jest jest-expo @types/jest @testing-library/react-native -- --save-dev

No 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:

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:

text
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.js

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

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

ts
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:

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.

ts
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:

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:

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.

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

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

tsx
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:

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.

ts
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:

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:

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.

tsx
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:

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:

bash
npm run lint
npm run typecheck

Ambos terminaram sem diagnóstico no projeto editorial. Em seguida, rode as suítes:

bash
npm test -- --ci

O resultado real, depois de integrar a API à tela, foi:

PASS habit-card · PASS habits · PASS storage · PASS daily-tip · Test Suites: 4 passed, 4 total · Tests: 6 passed, 6 total

Confira também a saúde da matriz instalada:

bash
npx expo install --check
npx expo-doctor@1.20.2

A 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:

bash
npx expo export --platform all --output-dir dist-proof

O 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:

Android Bundled 1245 modules · iOS Bundled 1115 modules · Web Bundled 786 modules · Static routes: /, /habitos/[id] · Exported: dist-proof

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:

text
[ ] 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 verdes

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

  • app react native
  • projeto expo typescript
  • tutorial react native
  • expo router
  • asyncstorage
  • testes mobile

Perguntas frequentes

Preciso de Android Studio ou Xcode para acompanhar o tutorial?
Não para escrever, testar e exportar o bundle. Para conferir gestos, teclado e aparência nativa, use depois um dispositivo, emulador Android ou simulador iOS com o ambiente correspondente configurado.
Por que o tutorial usa React Native 0.86 se existe uma versão mais nova?
Porque Expo SDK 57 foi lançado com React Native 0.86 e o projeto deve respeitar a matriz compatível do SDK. Instalar versões isoladamente por serem mais novas pode quebrar módulos e ferramentas do Expo.
AsyncStorage é um banco de dados seguro?
Não. Ele é um armazenamento chave-valor persistente e não criptografado, adequado aqui para uma lista pequena. Não guarde senhas ou segredos e avalie uma solução de dados apropriada quando o produto crescer.
O app deste tutorial foi realmente testado?
Sim para lint, TypeScript, seis testes Jest, Expo Doctor e export Android, iOS e web. Não houve execução visual em aparelho ou submissão às lojas; esses limites estão indicados no corpo.

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, 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

  1. Expo — documentação da versão atual — docs.expo.dev
  2. Expo — Create a project — docs.expo.dev
  3. Expo Router — Introduction — docs.expo.dev
  4. React Native — FlatList — reactnative.dev
  5. Expo — Unit testing with Jest — docs.expo.dev

Continue por aqui