DEV Community

Cover image for Construindo Apps Local-First com Legend State no React Native
Luma Montes
Luma Montes

Posted on • Edited on

Construindo Apps Local-First com Legend State no React Native

Se você quiser ir direto para o código, esse repositório aqui tá contendo uma API e um app com Expo que implementam tudo que falei nesse artigo:

Local-First é um conceito super popular no desenvolvimento de aplicações modernas que oferece uma ótima experiência para o usuário. Na prática, é um conceito bem direto: todas as ações são "cacheadas" localmente antes de serem sincronizadas com o backend, permitindo que o app funcione mesmo offline.

Os desafios do Local-First

Implementar traz alguns desafios bem específicos:

  • Como monitorar o status de conectividade do usuário que fica mudando o tempo todo?
  • Como disparar a sincronização com o backend quando voltar online?
  • Vou precisar fazer todo esse gerenciamento do zero no meu projeto?

Felizmente, existem diversas soluções que facilitam a implementação do Local-First em diferentes aplicações, mas neste artigo eu vou falar um pouquinho sobre uma lib mais recente e bem legal que oferece uma solução elegante para esse problema: Legend State.

Por que Legend State?

O Legend State é extremamente rápido (dá uma olhada nos benchmarks) e se trata de uma lib de gerenciamento de estado local e remoto com um poderoso sistema de sincronização que funciona com qualquer backend.

Maaaas para ser tão rápido, o Legend State possui um conceito de "reatividade" um pouco diferente do React tradicional. Não vou entrar em muitos detalhes aqui, mas recomendo dar uma olhada na documentação oficial sobre, existe alguns conceitos novos que precisamos aprender para usar a lib da melhor forma.

Um ponto que também vale a pena mencionar é que versão recomendada atualmente pelo Legend State ainda está em beta, mas bem próximo de um lançamento oficial.

Vamos ao código!

Vamos criar um app de posts que funciona offline-first, com sincronização automática quando online. Primeiro, vamos configurar nossa store:

// store/posts.ts
import { observable } from '@legendapp/state';
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage';
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv';
import { syncedCrud } from '@legendapp/state/sync-plugins/crud';
import { Platform } from 'react-native';

export interface Post {
  id?: string;
  title: string;
  content: string;
  createdAt?: string;
  updatedAt?: string;
}

export type CreatePostInput = Pick<Post, 'title' | 'content'>;

export type CreatePostOutput = {
  status: string;
  data: Post;
  message: string;
}

const API_URL = 'http://localhost:3000/api';

const getPosts = async () => {
  const response = await fetch(`${API_URL}/posts`);
  return response.json().then(data => {
    return data.data;
  });
};

const createPost = async (input: CreatePostInput): Promise<Post> => {
  const response = await fetch(`${API_URL}/posts`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  const result = await response.json();
  return result.data;
};

const updatePost = async (input: Partial<Post>) => {
  const response = await fetch(`${API_URL}/posts/${input.id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  return response.json();
};

const deletePost = async (input: Post) => {
  await fetch(`${API_URL}/posts/${input.id}`, {
    method: 'DELETE',
  });
  return { id: input.id };
};

export const postStore$ = observable(syncedCrud<CreatePostInput, CreatePostInput, 'array'>({
  initial: [] as Record<string, Post>[],
  as: 'array',
  list: getPosts, // Aqui usamos list pois nosso endpoint retorna // um conjunto de dados. caso retornasse somente um dado, usariamos // get e não list
  create: createPost,
  update: updatePost,
  delete: deletePost,
  persist: {
    name: 'posts',
    plugin: Platform.OS === 'web' ? ObservablePersistLocalStorage : ObservablePersistMMKV,
  },
  onSaved: (data) => {
    //Quando o post é salvo no backend, recebemos a resposta aqui
    //E podemos atualizar o post no store de acordo com os dados do post salvo no backend, caso quisermos
    //Por exemplo, se quisermos atualizar o post com o id do post salvo no backend etc
    return {
      ...data.saved,
    }
  },
  retry: {
    infinite: true, // Continua tentando em caso de erro
  },
  syncMode: 'auto',
  fieldUpdatedAt: 'updatedAt',
  fieldCreatedAt: 'createdAt',
}));
Enter fullscreen mode Exit fullscreen mode

Perceba que usamos o syncedCrud, um plugin pronto criado pelo Legend State que pode se conectar com qualquer backend. Com ele, basta a gente definir quais funções se conectam aos endpoints para ter toda a lógica de sincronização pronta (bem show).

O código acima já configura:

  1. A tipagem
  2. Os métodos CRUD que se comunicam com a API. Então o list seria o endpoint getAll do meu CRUD, e assim por diante
  3. A persistência de dados multiplataforma (LocalStorage na web, MMKV no mobile)
  4. O modo de sincronização automática

Implementando o componente principal

O componente principal do app mostra como usar o store e lidar com conectividade:

// App.tsx
import { useEffect, useState } from 'react';
import { View, Text, ScrollView } from 'react-native';
import { observer, use$ } from '@legendapp/state/react';
import { useObservable } from '@legendapp/state/react';
import NetInfo from '@react-native-community/netinfo';
import { syncState } from "@legendapp/state"

import { postStore$ } from 'store/posts';
import { Post } from 'types/post';
import { CreatePostForm } from './components/CreatePostForm';
import { PostCard } from './components/PostCard';

const App = observer(() => { //Adicionamos o observer para esse componente ficar verificando as mudanças 
  const connectivityStatus$ = useObservable<string>('checking');
  const posts = use$(postStore$)
  const state$ = syncState(postStore$);
  const isLoaded = state$.isLoaded.get();
  const isError = state$.error.get();
  const error = state$.error.get();

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      connectivityStatus$.set(state.isConnected ? 'online' : 'offline');
    });

    return () => unsubscribe();
  }, []);

  return (
    <View className="flex-1 bg-gray-50">
      <ScrollView className="flex-1 px-4 pt-12">
        <View className="mb-6">
          <Text className="text-3xl font-bold text-gray-900">Posts</Text>
        </View>

        <ConnectionStatus status={connectivityStatus$.get()} />

        {isError && (
          <View className="mb-6 rounded-lg bg-red-100 p-4">
            <Text className="text-sm font-medium text-red-800">Error {error?.message}</Text>
          </View>
        )}

        {!isLoaded && (
          <View className="mb-6 rounded-lg bg-blue-100 p-4">
            <Text className="text-sm font-medium text-blue-800">Carregando posts...</Text>
          </View>
        )}

        {Object.entries(posts || {}).map(([id, post]) => (
          <PostCard key={id} post={{ ...post, id }} />
        ))}

        {posts.length === 0 && (
          <View className="mb-6 rounded-lg bg-gray-100 p-4">
            <Text className="text-sm font-medium text-gray-800">Nenhum post encontrado</Text>
          </View>
        )}
      </ScrollView>
    </View>
  );
});

export default App;
Enter fullscreen mode Exit fullscreen mode

O componente acima:

  1. Monitora o status de conectividade com o NetInfo
  2. Exibe um indicador visual para informar o usuário se está online ou offline
  3. Mostra estados de loading e erro conforme necessário
  4. Renderiza a lista de posts, já usando nossa store!

E agora para criar, atualizar e deletar os registros?

Basta chamarmos a nossa store e manipularmos diretamente o nosso array: dando um push para criar, um set para atualizar e um delete para deletar.

Por exemplo, o código no componente de card do post ficaria assim:

const PostCard = observer(({ post }: { post: Post }) => {
  const postId = post.id;
  const handleUpdate = () => {
    postStore$[postId].set({
      ...postStore$[postId].get(),
      title,
      content,
    }) //Aqui automaticamente será chamado o endpoint de PUT
  };

  const handleDelete = () => {
    postStore$[postId].delete(); //Automaticamente sera chamado o endpoint de delete que configuramos
  };

  ...resto do código
  };
});
Enter fullscreen mode Exit fullscreen mode

E o código no componente de criação do post:

const CreatePostForm = observer(() => {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const handleSubmit = () => {
    const randomId = Date.now().toString(); //Adiciona um id aleatório para o post, para salvar no local (e depois salvar no backend)
    if (title && content) {
      const input = { id: randomId, title, content, author: 'Lumix'};
      postStore$.push(input); //Aqui adicionamos na nossa store, e automaticamente será chamado o endpoint POST
      setTitle('');
      setContent('');
    }
  };

  return (
    ...resto do código
  );
});
Enter fullscreen mode Exit fullscreen mode

Prontinho! Nosso CRUD está pronto, com sincronização de dados offline que se conecta automaticamente com nosso backend ao mudar o status de conectividade do usuário :)

Conseguimos implementar:

  1. Persistência Cross-Platform:

    • Web: LocalStorage
    • Mobile: MMKV
  2. Sincronização Automática:

    • Mudanças são salvas localmente primeiro
    • Sincronização automática quando online
  3. UI Responsiva:

    • Feedback imediato para o usuário
    • Indicador de status de conexão
    • Loading e error states
  4. CRUD Completo:

    • Create: Cria posts mesmo offline
    • Read: Lista posts do cache local
    • Update: Atualiza posts com sync automático
    • Delete: Remove posts com sync quando online

Menção honrosa: Hook onSaved

Um recurso interessante do syncedCrud é o hook onSaved, que nos permite receber e processar a resposta do backend após uma operação ser sincronizada. No nosso exemplo:

onSaved: (data) => {
  //Quando o post é salvo no backend, recebemos a resposta aqui
  //E podemos atualizar o post no store de acordo com os dados do post salvo no backend
  return {
    ...data.saved,
  }
}
Enter fullscreen mode Exit fullscreen mode

Isso é útil para casos onde o backend pode adicionar informações extras ao nosso objeto (como IDs gerados pelo banco, timestamps atualizados, etc).

Conclusão

Com o Legend State, implementamos um app Local-First robusto com poucas linhas de código. A biblioteca cuida de toda a complexidade de sincronização, persistência e gerenciamento de estado, adiantando bastante o tempo de desenvolvimento.

Recursos úteis:

Top comments (9)

Collapse
 
pedrovian4 profile image
pedrovian4

Bom demais, o processo de sincronização ocorre também quando o App está aberto em background?

Collapse
 
lumamontes profile image
Luma Montes

valeu migo <3 se não me engano, o processo de sync deles funciona apenas quando a conexão é retomada dentro do aplicativo ativo. mas também tem a opção de sincronização manual, então provavelmente é possível implementar de forma personalizada a sincronização em background usando um expo-background-task da vida!

Collapse
 
pedrovian4 profile image
pedrovian4

Uh nice, vou da uma testadaa

Collapse
 
lorenalgm profile image
Lorena GM

Arrasou! Bem interessante esse Legend State

Collapse
 
corpas profile image
Benjamin

Olá, seu projeto está ótimo. Consegui testá-lo em um POC interno.

A única coisa que não consegui fazer foi capturar os erros de chamada no front end.

Você poderia me ajudar com isso?

Atenciosamente e muito obrigado. Bom trabalho 😁

Collapse
 
lumamontes profile image
Luma Montes

oiee, que bom que deu certo!! posso ajudar sim, como você tem tentado capturar os erros?

Collapse
 
corpas profile image
Benjamin

Olá, muito obrigado por sua resposta.

Tentei capturar no front-end qualquer erro de uma solicitação que é executada na sincronização de engenharia/estado, mas parece que, da forma como está implementado, esse erro não aparece na exibição.

Você pode confirmar que nenhum desses erros está ocorrendo? Porque estou um pouco confuso.

Saudações e obrigado ✌️

Thread Thread
 
lumamontes profile image
Luma Montes

Entendi!! Dei uma testada aqui forçando o erro da requisição e realmente não apareceu na exibição!! Provavelmente porque no meu exemplo, tô usando o retry de forma infinita sem um try catch. ou seja, vai ficar sempre tentando novamente e não dispara o erro.

Adicione um try catch por volta da chamada a API ( e precisa dar um throw no erro quando a resposta não estiver OK e no catch também) e remova o retry infinito, deixa em um valor menor tipo tentar umas 3 vezes! Acho que assim dá bom

export const postStore$ = observable(syncedCrud({
...resto
retry: {
times: 1
},
syncMode: 'auto',
fieldUpdatedAt: 'updatedAt',
fieldCreatedAt: 'createdAt',
}));

const getPosts = async () => {
try {
const response = await fetch(${API_URL}/psosts); //Url errada pra forçar erro
if(!response.ok) {
throw new Error(Error fetching posts: ${response.statusText}); // Isso vai ir lá pro error que está sendo renderizado no front
}
const data = await response.json();
return data.data;
} catch (error) {
console.error('Error fetching posts:', error);
throw error; //Precisamos lançar o erro pra propagar
}
};

Thread Thread
 
corpas profile image
Benjamin

Muito bem, isso é ótimo! Muito obrigado por sua atenção, você foi de grande ajuda.

Boa sorte em seus próximos projetos 😁✌️