React Native do Zero: Instalar, Testar e Publicar um App Mobile

React Native do Zero: Instalar, Testar e Publicar um App Mobile

Com React Native você cria aplicativos para Android e iOS com JavaScript e React. Você escreve os componentes uma vez, e o mesmo código roda nas duas plataformas como interface nativa de verdade. Este artigo cria o projeto com Expo, passa por seis pequenos exemplos, testa cada um com Jest e mostra como exportar e publicar o aplicativo. Cada arquivo de código aqui é um arquivo real que foi testado, e a saída dos testes é a saída de verdade.

O que você precisa

Você precisa de Node.js (este artigo foi testado com Node 24.19.0), npm, um editor de código e um celular com o aplicativo Expo Go ou um emulador Android para a etapa de execução. Os testes rodam no seu computador e não precisam de celular. A build de publicação exige uma conta Expo gratuita para o EAS Build. Publicar na Google Play exige uma conta de desenvolvedor do Google Play (taxa única de 25 dólares americanos). Publicar na App Store exige uma conta Apple Developer (taxa anual).

Ilustração

Como o React Native executa seu código: o Metro empacota o JavaScript, o Hermes o executa no celular e a interface nativa é desenhada
Como o React Native executa seu código: o Metro empacota o JavaScript, o Hermes o executa no celular e a interface nativa é desenhada

1. Instale e crie o projeto

Crie um projeto Expo a partir de um modelo em branco e instale os pacotes de teste e de web. npx expo install escolhe as versões compatíveis com o SDK do Expo, então use esse comando para os pacotes suportados pelo Expo. Execute o aplicativo com npx expo start e leia o código QR com o Expo Go, ou pressione w para abrir no navegador.

npx create-expo-app@latest app --template blank
cd app
npx expo install jest-expo jest react-dom react-native-web @expo/metro-runtime
npm install -D @testing-library/react-native@14 test-renderer@1
npx expo start

Acrescente em `package.json`:

"jest": {
  "preset": "jest-expo"
},
"scripts": {
  "test": "jest"
}

Exemplo 1: Olá: componentes e StyleSheet

Componente é uma função que devolve o que deve ser exibido. View e Text substituem div e p, e StyleSheet.create organiza os objetos de estilo.

import { StyleSheet, Text, View } from 'react-native';

// Componente é uma função que devolve o que aparece na tela.
export default function Hello() {
  return (
    <View style={styles.box}>
      <Text style={styles.title}>Olá, React Native</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { padding: 16, backgroundColor: '#e8f1ff', borderRadius: 8 },
  title: { fontSize: 20, fontWeight: 'bold', color: '#1b3a6b' },
});

Resultado real do teste

✓ 1. Hello › shows the heading
Árvore de componentes: App renderiza seis seções, e cada seção envolve um componente de exemplo
Árvore de componentes: App renderiza seis seções, e cada seção envolve um componente de exemplo

Exemplo 2: Props: enviando dados a um componente

App passa name para Greeting por meio de props. O mesmo componente pode mostrar nomes diferentes sem ser editado.

import { Text } from 'react-native';

// Props são dados que o componente pai entrega. O mesmo componente pode mostrar nomes diferentes.
export default function Greeting({ name }) {
  return <Text style={{ fontSize: 18 }}>{`Olá, ${name}`}</Text>;
}

Resultado real do teste

✓ 2. Props › uses the name prop

Exemplo 3: State: contador com Pressable

useState guarda a contagem, e Pressable é um elemento que pode ser tocado. A forma de função setCount(c => c + 1) sempre usa o valor mais recente.

import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

// State é um valor que muda. Chamar o setter faz o React renderizar de novo.
export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <View style={styles.row}>
      <Pressable
        style={styles.button}
        accessibilityRole="button"
        accessibilityLabel="decrease"
        onPress={() => setCount(c => c - 1)}>
        <Text style={styles.buttonText}>-</Text>
      </Pressable>
      <Text testID="count" style={styles.count}>{count}</Text>
      <Pressable
        style={styles.button}
        accessibilityRole="button"
        accessibilityLabel="increase"
        onPress={() => setCount(c => c + 1)}>
        <Text style={styles.buttonText}>+</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 16 },
  button: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#2f6fed', alignItems: 'center', justifyContent: 'center' },
  buttonText: { color: '#fff', fontSize: 24, fontWeight: 'bold' },
  count: { fontSize: 28, minWidth: 48, textAlign: 'center' },
});

Resultado real do teste

✓ 3. State › increments and decrements the count

Exemplo 4: TextInput controlado

O valor do campo fica guardado no state. onChangeText o atualiza a cada tecla, e o texto de pré-visualização é calculado a partir desse state.

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

// TextInput controlado: o valor vem do state, e onChangeText o atualiza a cada tecla.
export default function NameForm() {
  const [name, setName] = useState('');
  const trimmed = name.trim();

  return (
    <View style={styles.box}>
      <TextInput
        testID="name-input"
        style={styles.input}
        placeholder="Seu nome"
        value={name}
        onChangeText={setName}
      />
      <Text testID="preview" style={styles.preview}>
        {trimmed ? `Oi, ${trimmed}!` : 'Digite seu nome acima'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { gap: 8 },
  input: { borderWidth: 1, borderColor: '#9aa6c2', borderRadius: 8, padding: 10, fontSize: 16 },
  preview: { fontSize: 16, color: '#333' },
});

Resultado real do teste

✓ 4. Controlled input › shows a greeting from the typed name

Exemplo 5: Lista com adição de itens

Adicione itens criando um novo array com spread, em vez de alterar o array antigo. map basta para listas curtas; para listas longas, use FlatList com keyExtractor.

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

// Adicione itens criando um novo array com spread, em vez de alterar o array antigo.
export default function TodoList() {
  const [text, setText] = useState('');
  const [items, setItems] = useState([{ id: '1', title: 'Aprender componentes' }]);

  function addItem() {
    const title = text.trim();
    if (!title) return;
    setItems([...items, { id: `${Date.now()}-${items.length}`, title }]);
    setText('');
  }

  return (
    <View style={styles.box}>
      <View style={styles.row}>
        <TextInput
          testID="todo-input"
          style={styles.input}
          placeholder="Nova tarefa"
          value={text}
          onChangeText={setText}
        />
        <Pressable testID="add" style={styles.button} accessibilityRole="button" onPress={addItem}>
          <Text style={styles.buttonText}>Add</Text>
        </Pressable>
      </View>
      {items.map(item => (
        <Text key={item.id} testID="todo-item" style={styles.item}>{item.title}</Text>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  box: { gap: 8 },
  row: { flexDirection: 'row', gap: 8 },
  input: { flex: 1, borderWidth: 1, borderColor: '#9aa6c2', borderRadius: 8, padding: 10, fontSize: 16 },
  button: { backgroundColor: '#2f6fed', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
  buttonText: { color: '#fff', fontWeight: 'bold' },
  item: { fontSize: 16, paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ccc' },
});

Resultado real do teste

✓ 5. List with add › adds a task and clears the input
✓ 5. List with add › ignores an empty task

Exemplo 6: Carregando dados com useEffect e fetch

useEffect roda depois da primeira renderização. O componente mostra um ActivityIndicator enquanto a requisição está em andamento, e então exibe a lista de usuários. A função de limpeza ignora respostas que chegam depois que o componente já foi removido.

import { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';

export const USERS_URL = 'https://jsonplaceholder.typicode.com/users';

// useEffect roda depois da primeira renderização. Aqui ele carrega dados de uma API web.
// A função de limpeza ignora respostas que chegam depois que o componente já não existe.
export default function Users({ url = USERS_URL }) {
  const [users, setUsers] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => { if (active) setUsers(data); })
      .catch(err => { if (active) setError(err.message); });
    return () => { active = false; };
  }, [url]);

  if (error) return <Text testID="error">{`Erro: ${error}`}</Text>;
  if (!users) return <ActivityIndicator testID="loading" />;

  return (
    <View testID="users" style={{ gap: 6 }}>
      {users.map(u => <Text key={u.id}>{u.name}</Text>)}
    </View>
  );
}

Resultado real do teste

✓ 6. Load data › shows a loading indicator while the request is pending
✓ 6. Load data › shows the users after the request finishes
✓ 6. Load data › shows an error when the response is not ok

Reunindo tudo no App.js

import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, Text, View } from 'react-native';

import Hello from './src/examples/Hello';
import Greeting from './src/examples/Greeting';
import Counter from './src/examples/Counter';
import NameForm from './src/examples/NameForm';
import TodoList from './src/examples/TodoList';
import Users from './src/examples/Users';

// O componente raiz junta os componentes pequenos, como os exemplos do artigo.
export default function App() {
  return (
    <ScrollView contentContainerStyle={styles.container}>
      <StatusBar style="auto" />
      <Text style={styles.heading}>Exemplos de React Native</Text>

      <Section title="1. Hello"><Hello /></Section>
      <Section title="2. Props"><Greeting name="Budi" /></Section>
      <Section title="3. State"><Counter /></Section>
      <Section title="4. Controlled input"><NameForm /></Section>
      <Section title="5. List with add"><TodoList /></Section>
      <Section title="6. Load data"><Users /></Section>
    </ScrollView>
  );
}

function Section({ title, children }) {
  return (
    <View style={styles.section}>
      <Text style={styles.sectionTitle}>{title}</Text>
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20, paddingTop: 60, gap: 24 },
  heading: { fontSize: 26, fontWeight: 'bold' },
  section: { gap: 10 },
  sectionTitle: { fontSize: 14, fontWeight: '600', color: '#2f6fed', textTransform: 'uppercase' },
});

2. Teste cada exemplo com Jest

O testador usado é o Jest com o preset jest-expo, que prepara o React Native para rodar no Node. O @testing-library/react-native renderiza os componentes e interage com eles como um usuário: procura textos ou rótulos, toca neles ou altera o texto. Na versão 14, render e fireEvent são assíncronos, então cada chamada é escrita com await. O teste de Users substitui global.fetch por uma função falsa, então nenhuma requisição de rede é enviada durante os testes. Rode npm test para executar tudo.

npm test

Arquivo de teste

import { fireEvent, render, screen } from '@testing-library/react-native';

import Hello from '../src/examples/Hello';
import Greeting from '../src/examples/Greeting';
import Counter from '../src/examples/Counter';
import NameForm from '../src/examples/NameForm';
import TodoList from '../src/examples/TodoList';
import Users from '../src/examples/Users';

describe('1. Hello', () => {
  it('shows the heading', async () => {
    await render(<Hello />);
    expect(screen.getByText('Olá, React Native')).toBeTruthy();
  });
});

describe('2. Props', () => {
  it('uses the name prop', async () => {
    await render(<Greeting name="Budi" />);
    expect(screen.getByText('Olá, Budi')).toBeTruthy();
  });
});

describe('3. State', () => {
  it('increments and decrements the count', async () => {
    await render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('0');
    await fireEvent.press(screen.getByLabelText('increase'));
    await fireEvent.press(screen.getByLabelText('increase'));
    expect(screen.getByTestId('count')).toHaveTextContent('2');
    await fireEvent.press(screen.getByLabelText('decrease'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });
});

describe('4. Controlled input', () => {
  it('shows a greeting from the typed name', async () => {
    await render(<NameForm />);
    expect(screen.getByTestId('preview')).toHaveTextContent('Digite seu nome acima');
    await fireEvent.changeText(screen.getByTestId('name-input'), '  Budi ');
    expect(screen.getByTestId('preview')).toHaveTextContent('Oi, Budi!');
  });
});

describe('5. List with add', () => {
  it('adds a task and clears the input', async () => {
    await render(<TodoList />);
    await fireEvent.changeText(screen.getByTestId('todo-input'), 'Buy milk');
    await fireEvent.press(screen.getByTestId('add'));
    expect(screen.getByText('Buy milk')).toBeTruthy();
    expect(screen.getByTestId('todo-input')).toHaveProp('value', '');
  });

  it('ignores an empty task', async () => {
    await render(<TodoList />);
    await fireEvent.press(screen.getByTestId('add'));
    expect(screen.getAllByTestId('todo-item')).toHaveLength(1);
  });
});

describe('6. Load data', () => {
  afterEach(() => {
    delete global.fetch;
  });

  it('shows a loading indicator while the request is pending', async () => {
    global.fetch = jest.fn(() => new Promise(() => {}));
    await render(<Users url="https://test.local/users" />);
    expect(screen.getByTestId('loading')).toBeTruthy();
  });

  it('shows the users after the request finishes', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({ ok: true, json: () => Promise.resolve([{ id: 1, name: 'Leanne Graham' }]) }),
    );
    await render(<Users url="https://test.local/users" />);
    expect(await screen.findByText('Leanne Graham')).toBeTruthy();
    expect(global.fetch).toHaveBeenCalledWith('https://test.local/users');
  });

  it('shows an error when the response is not ok', async () => {
    global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 500 }));
    await render(<Users url="https://test.local/broken" />);
    expect(await screen.findByTestId('error')).toHaveTextContent('Erro: HTTP 500');
  });
});

Saída real

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        1.934 s, estimated 3 s
Ran all test suites.

3. Exportação e publicação

npx expo export --platform web grava um site estático na pasta dist. Você pode hospedar essa pasta no Cloudflare Pages, no Netlify ou em qualquer hospedagem estática. npx expo export --platform android grava o bundle JavaScript que o aplicativo nativo carrega, e mostra o tamanho dele. A publicação nas lojas usa o EAS Build, que gera o APK ou o AAB na nuvem da Expo e depois os envia. Os comandos de EAS abaixo exigem uma conta Expo e uma conta na loja, então não foram executados para este artigo. Os dois comandos de exportação foram executados, e a saída real é mostrada.

npx expo export --platform web
npx expo export --platform android
npx eas-cli@latest login
npx eas-cli@latest build --platform android --profile preview
npx eas-cli@latest submit --platform android
Passos desde a instalação até a loja de aplicativos. Os passos 1 a 3 são executados no seu computador; os passos 4 a 6 são o caminho de publicação
Passos desde a instalação até a loja de aplicativos. Os passos 1 a 3 são executados no seu computador; os passos 4 a 6 são o caminho de publicação

Saída real (export --platform web)

Starting Metro Bundler

Web Bundled 324ms index.js (155 modules)

› web bundles (1):
_expo/static/js/web/index-2cd770f3379787ce05344930a3b77994.js (411KB)

› Files (3):
favicon.ico (15KB)
index.html (1.2KB)
metadata.json (49B)

Exported: dist

Saída real (export --platform android)

Starting Metro Bundler

Android Bundled 4251ms index.js (622 modules)

› android bundles (1):
_expo/static/js/android/index-2f8b5ff9ff67c29c92fd35ff3cb9dfec.hbc (1.6MB)

› Files (1):
metadata.json (150B)

Exported: dist

Próximos passos

Em seguida, adicione navegação com o Expo Router, que usa um arquivo para cada tela. Guarde dados com AsyncStorage ou expo-sqlite. Antes de publicar, prepare o nome do aplicativo, o ícone, a política de privacidade e as capturas de tela para a loja, e teste em um celular de verdade, porque o emulador não mostra tudo. Para iOS você precisa de uma conta Apple Developer, e o EAS Build pode gerar a build sem Mac.

Resumo

Um aplicativo React Native é uma árvore de componentes que desenha interfaces nativas. O Expo prepara o projeto, o Jest verifica cada exemplo, e a etapa de exportação ou o EAS Build transforma o projeto em um site ou em um arquivo de publicação. Comece com componentes pequenos, teste cada um e publique quando o aplicativo estiver funcionando.