DEV Community

Cover image for App React Native offline-first
Seleziomar Júnior
Seleziomar Júnior

Posted on

App React Native offline-first

Como construí um app mobile offline-first para gerenciar check-ins de milhares de pessoas em eventos — e por que cada decisão de arquitetura nasceu de um problema real

Imagina o cenário: um evento corporativo com 2.000 participantes. Staff espalhado por checkpoints de atividades, transporte, palestras, brindes, bagagem. Cada pessoa precisa ter seu check-in registrado em tempo real. O problema? O Wi-Fi do centro de convenções é instável, a rede 4G oscila, e se o app trava, o evento não espera.

Foi esse o desafio que me levou a construir um app React Native com Expo que precisou ser pensado do zero para funcionar mesmo quando a internet decide não colaborar.

Vou compartilhar as decisões técnicas e os problemas reais que cada uma resolve.


A Arquitetura

A estrutura do projeto foi organizada para separar responsabilidades de forma clara. Cada camada tem um papel bem definido:

/majoo-staff
├── /app                              # Rotas (Expo Router - file-based)
│   ├── _layout.tsx                   # Layout raiz: tema, auth, providers
│   ├── (unauthenticated)/            # Login, recuperação de senha
│   └── (authenticated)/              # Telas protegidas
│       ├── Home/                     # Dashboard com categorias
│       ├── checkpoints/
│       │   ├── [type]/index.tsx      # Lista por categoria
│       │   └── [type]/[id]/          # Detalhe + lista de usuários
│       │       └── qrcode/           # Scanner QR
│       ├── search/                   # Busca global
│       ├── checkins/                 # Histórico
│       └── reports/                  # Relatórios com export PDF
│
├── /components                       # UI reutilizável
│   ├── /Button                       # Variantes (primary, float, checkin)
│   ├── /Input                        # Text, search, underline
│   ├── /Layout                       # Headers, Footers, Profile
│   ├── /Modal, /Loader, /Hr
│   ├── ThemedText.tsx
│   └── ThemedView.tsx
│
├── /hooks                            # Lógica de negócio encapsulada
│   ├── useApi.ts                     # Wrapper HTTP com Bearer token
│   ├── useApplications.ts           # Gestão de eventos + importação
│   ├── useCheckpoints.ts            # Check-ins + sincronização
│   └── useReports.tsx               # Geração de PDF
│
├── /contexts
│   └── AuthContext.tsx               # Estado global: user, app, auth
│
├── /models                           # Camada de dados (SQLite)
│   ├── useCheckin.ts                # CRUD de check-ins locais
│   └── useUsers.ts                  # Queries com JOINs relacionais
│
├── /utils
│   ├── db.js                        # Schema SQLite + índices
│   ├── types.ts                     # Tipos TypeScript
│   ├── helpers.ts                   # Criptografia, storage, formatação
│   └── validations.ts              # Validações de entrada
│
└── /constants
    ├── CheckpointTypes.tsx          # Categorias com ícones
    └── Colors.ts                    # Tema claro/escuro
Enter fullscreen mode Exit fullscreen mode

A decisão de usar Expo Router com route groups ((authenticated) e (unauthenticated)) resolve um problema clássico: o guard de autenticação vive no layout, não espalhado por cada tela. Se o usuário não está logado, o redirect acontece em um único ponto.

Os hooks customizados (useCheckpoints, useApplications, useReports) funcionam como a camada de serviço do app. Eles encapsulam API, banco local e lógica de negócio — os componentes de tela ficam limpos, só consomem dados e renderizam.

Os models (useCheckin, useUsers) isolam o acesso ao SQLite. Nenhum componente faz query direta no banco. Isso cria uma camada de abstração que permite trocar a estratégia de persistência sem reescrever telas.


Offline-First: onde o app realmente se diferencia

Como requisito, o app deveria funcionar independente do status da conexão. É muito comum que em eventos e locais mais remotos não tenha uma conexão estável, então era muito necessário que o staff conseguisse ter acesso a lista em fazer checkin mesmo que não houvesse uma conexão.

A solução foi inverter a lógica: o banco local é a fonte de verdade, a API é apenas o mecanismo de sincronização.

1. Importação de dados com paginação e transações atômicas

Quando o staff se conecta a um evento, o app importa toda a base de usuários da API para o SQLite local. A importação é paginada e recursiva, processando cada página dentro de uma transação atômica:

const saveUsers = async (page = 1) => {

  // Busca uma página de usuários da API
  const response = await request(`/users?page=${page}`);

  // Transação atômica: ou salva tudo da página, ou não salva nada
  database.withTransactionSync(() => {

    users.map(async (user) => {

      // Verifica se o usuário já existe no banco local
      const exist = // ... consulta por ID

      if (!exist) {
        // Usuário novo: insere com todo o histórico de check-ins da API
      } else {
        // Usuário já existe: faz merge dos check-ins novos
        // sem sobrescrever os que foram feitos offline
      }
    });
  });

  // Se ainda tem páginas, chama recursivamente até importar tudo
  if (response.data.last_page > page) {
    return saveUsers(page + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

O merge inteligente garante que check-ins feitos offline nunca são sobrescritos quando a base é atualizada do servidor. Se o staff fez 50 check-ins sem internet e depois importa a base atualizada, nenhum desses 50 registros se perde.

2. Check-in local imediato — zero dependência de rede

Quando o staff registra um check-in (manual ou via QR Code criptografado com AES), o dado vai direto pro SQLite — sem esperar resposta da API:

const checkin = async (checkpoint_id, userId) => {

  // Verifica se já fez check-in nesse checkpoint (evita duplicidade)
  const exist = // ... consulta no banco local
  if (exist) return 'checked';

  // Salva direto no SQLite com synced = 0 (pendente de envio)
  // Nenhuma chamada de rede acontece aqui — feedback instantâneo
  const saved = // ... insert no banco local

  if (saved) {
    // Tenta sincronizar em background — não bloqueia o operador
    sync();
  }

  return status;
}
Enter fullscreen mode Exit fullscreen mode

O feedback pro operador é instantâneo. O check-in aparece na lista na hora. A rede não é bloqueante. Se não tem internet, o dado fica na fila local com synced = 0 e será enviado quando a conexão voltar.

3. Sincronização inteligente com drain recursivo

O sync() é o coração do sistema offline:

const sync = async () => {

  // Só tenta sincronizar se tiver internet
  const network = await Network.getNetworkStateAsync();
  if (!network.isConnected) return;

  // Busca check-ins pendentes em lotes de 20
  const checkins = // ... SELECT WHERE synced = 0 LIMIT 20
  const count = // ... COUNT total de pendentes

  if (checkins.length < 1) return;

  // Envia o lote inteiro pro servidor
  const response = // ... POST /checkins

  if (response.status) {

    // Caso a requisição seja efetuada com sucesso 
    // recebemos um feedback do servidor informando 
    // quais os checkins foram sincronizados. 
    // Caso algum em específico falhe, 
    // ele não volta na lista e continua na fila 
    // para ser sincronizado no futuro.
    for (let i in response.data) {
                if (response.data[i].id) {
                    // Faz update do checkin para sync = 1
                }
            }

    // Se ainda tem mais pendentes, agenda outra rodada em 500ms
    // Esvazia a fila progressivamente sem travar a UI
    if (count.total >= 20) {
      setTimeout(sync, 500);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

O ponto-chave: o sync roda em lotes de 20 com intervalo de 500ms entre eles. Isso garante que mesmo com centenas de check-ins acumulados offline, a fila é esvaziada progressivamente sem travar a UI do operador.

4. Modelo de dados relacional otimizado

O banco local usa duas tabelas com índices estratégicos para as queries mais frequentes:

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    name TEXT,
    email TEXT,
    document TEXT,
    application_id INTEGER NOT NULL,       -- A qual evento pertence
    checkin TEXT                            -- Histórico de check-ins (JSON da API)
    -- ...
);

CREATE TABLE checkins (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER,                       -- Quem fez check-in
    checkpoint_id INTEGER,                 -- Em qual checkpoint
    synced INTEGER DEFAULT 0,              -- 0 = pendente | 1 = enviado ao servidor
    created_at TEXT DEFAULT (datetime('now')),
    FOREIGN KEY(user_id) REFERENCES users(id)
    -- ...
);

-- Índices para acelerar as buscas mais comuns
CREATE INDEX application_id_idx ON users(application_id);
CREATE INDEX user_id_idx ON checkins(user_id);
CREATE INDEX checkpoint_id_idx ON checkins(checkpoint_id);
CREATE INDEX synced_idx ON checkins(synced);
Enter fullscreen mode Exit fullscreen mode

A flag synced é o coração do mecanismo offline: 0 significa "registrado localmente, aguardando envio", 1 significa "confirmado pelo servidor". Buscar "todos os usuários pendentes no checkpoint X" é um simples LEFT JOIN. SQL puro, performático, confiável.

5. Cache com fallback automático em todas as camadas

O padrão se repete em toda a camada de dados:

const get = async (type) => {

  // Tenta buscar da API
  const response = // ... GET /checkpoints

  if (response.status) {
    // API respondeu: salva no cache local e retorna dados frescos
  } else {
    // API falhou (sem internet, timeout): busca do cache local
  }

  // O app nunca retorna vazio — sempre tem um fallback
  return [];
}
Enter fullscreen mode Exit fullscreen mode

Isso vale para listagem de eventos, checkpoints, categorias, identificações. O app nunca mostra tela vazia por falta de rede — sempre tem um fallback local.


Conclusão

Construir um app offline-first não é apenas adicionar cache — é mudar a arquitetura. O banco local passa a ser a fonte de verdade, enquanto a API vira apenas um mecanismo de sincronização.

Se você está construindo apps que precisam funcionar em cenários de conectividade instável — eventos, campo, indústria — invista tempo no design do seu mecanismo de sync. É lá que mora a diferença entre um app que deveria funcionar e um que funciona de verdade.

Top comments (0)