React Native + RxDB: как строить Local-First приложения. Архитектура и примеры

Время прочтения — 10 минут
Содержание
Представьте курьера, который не может открыть маршрут из-за плохой связи, или менеджера склада, который не видит список товаров, потому что сервер недоступен. Такие сбои могут замедлить бизнес-процессы, сорвать сроки и привести к убыткам. Поэтому в некоторых сценариях мобильные приложения должны продолжать работу даже без доступа к интернету.
У многих наших клиентов бывают проблемы со связью – на удалённых объектах, выездных мероприятиях, в логистике или на складах. В таких условиях важно, чтобы данные на устройстве оставались доступны, а все изменения автоматически подтягивались на сервер, когда интернет снова появится.
Такие приложения строятся по принципу local-first: основная работа с данными происходит локально, а сервер используется для синхронизации и объединения информации между устройствами. Это позволяет обеспечить стабильную и надёжную работу независимо от текущего состояния сети.
В этой статье мы разберём на примере, как можно реализовать local-first мобильное приложение. Вы узнаете:
  • Как устроена двусторонняя синхронизация между клиентом и сервером?
  • Как работает обработка конфликтов?
  • Что важно учитывать при разработке таких решений?
Ищете разработчиков для вашего проекта? Мы готовы стать техническими партнерами

Стек технологий

Для нашего примера мы использовали следующие технологии:
  • Клиент: React Native + react-native-nitro-sqlite + RxDB;
  • Сервер: NestJS + TypeORM + PostgreSQL;
  • React Native – фреймворк для кроссплатформенной разработки мобильных приложений, который позволяет создавать нативные интерфейсы для iOS и Android с использованием JavaScript и React;
  • react-native-nitro-sqlite – библиотека для React Native, которая обеспечивает управление локальными базами данных SQLite на устройстве;
  • RxDB – клиентская база данных, ориентированная на оффлайн-режим и реактивную работу с данными. Она хранит документы локально, позволяет выполнять запросы с подпиской на изменения и поддерживает автоматическую синхронизацию с удалённым сервером;
  • NestJS – фреймворк для создания серверных приложений на Node.js;
  • TypeORM – ORM-библиотека для TypeScript и JavaScript, которая обеспечивает удобную работу с реляционными базами данных;
  • PostgreSQL – объектно-реляционная система управления базами данных.

Локальное хранилище RxDB через SQLite

Начинаем с настройки локальной базы данных, через которую RxDB будет взаимодействовать с SQLite с поддержкой валидации и шифрования.
В мобильном приложении RxDB хранит данные в локальной SQLite-базе, структура которой реализуется в виде SQL-таблиц. Для взаимодействия с этой базой используется плагин storage-sqlite, позволяющий RxDB преобразовывать коллекции и документы в SQL-запросы и работать поверх SQLite как с документной базой с реактивным API.
//storage.ts
import {
  getRxStorageSQLiteTrial,
  getSQLiteBasicsQuickSQLite,
} from 'rxdb/plugins/storage-sqlite';
import { open } from 'react-native-nitro-sqlite';
import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js';
const sqliteBasics = getSQLiteBasicsQuickSQLite(open);
const storage = getRxStorageSQLiteTrial({ sqliteBasics });
const validatedStorage = wrappedValidateAjvStorage({ storage });
const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({
  storage: validatedStorage,
});
export { encryptedStorage };

Создание экземпляра базы данных

Далее переходим к созданию менеджера базы данных и инициализации коллекций. Полный код класса выглядит так. Ниже разберём его ключевые методы.
//Instance.ts
import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb';
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
import { replicateRxCollection } from 'rxdb/plugins/replication';
import NetInfo from '@react-native-community/netinfo';
import {
  CheckPointType,
  MyDatabaseCollections,
  ReplicateCollectionDto,
} from './types.ts';
import { encryptedStorage } from './storage.ts';
import { defaultConflictHandler } from './utills.ts';
import { usersApi, userSchema, UserType } from '../features/users';
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder';
import { RxDBUpdatePlugin } from 'rxdb/plugins/update';
// for support query.update method
addRxPlugin(RxDBUpdatePlugin);
// for support chained query methods
addRxPlugin(RxDBQueryBuilderPlugin);
// for enabling data migration
addRxPlugin(RxDBMigrationSchemaPlugin);
export class RxDatabaseManager {
  private static instance: RxDatabaseManager;
  private db: RxDatabase<MyDatabaseCollections> | null = null;
  private isOnline = false;
  private constructor() {}
  public static getInstance(): RxDatabaseManager {
    if (!RxDatabaseManager.instance) {
      RxDatabaseManager.instance = new RxDatabaseManager();
    }
    return RxDatabaseManager.instance;
  }
  public async init(): Promise<RxDatabase<MyDatabaseCollections>> {
    if (this.db) return this.db;
    if (__DEV__) {
      // needs to be added in dev mode
      addRxPlugin(RxDBDevModePlugin);
    }
    this.db = await createRxDatabase<MyDatabaseCollections>({
      name: 'myDb',
      storage: encryptedStorage,
      multiInstance: false, // No multi-instance support for React Native
      closeDuplicates: true, // Close duplicate database instances
    });
    await this.db.addCollections({
      users: {
        schema: userSchema,
        conflictHandler: defaultConflictHandler,
        migrationStrategies: {
          // 1: function (oldDoc: UserType) {},
        },
      },
    });
    this.setupConnectivityListener();
    return this.db;
  }
  public getDb(): RxDatabase<MyDatabaseCollections> {
    if (!this.db) {
      throw new Error('Database not initialized. Call init() first.');
    }
    return this.db;
  }
  private replicateCollection<T>(dto: ReplicateCollectionDto<T>) {
    const { collection, replicationId, api } = dto;
    const replicationState = replicateRxCollection<WithDeleted<T>, number>({
      collection: collection,
      replicationIdentifier: replicationId,
      pull: {
        async handler(checkpointOrNull: unknown, batchSize: number) {
          const typedCheckpoint = checkpointOrNull as CheckPointType;
          const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
          const id = typedCheckpoint ? typedCheckpoint.id : '';
          const response = await api.pull({ updatedAt, id, batchSize });
          return {
            documents: response.data.documents,
            checkpoint: response.data.checkpoint,
          };
        },
        batchSize: 20,
      },
      push: {
        async handler(changeRows) {
          console.log('push');
          const response = await api.push({ changeRows });
          return response.data;
        },
      },
    });
    replicationState.active$.subscribe(v => {
      console.log('Replication active$:', v);
    });
    replicationState.canceled$.subscribe(v => {
      console.log('Replication canceled$:', v);
    });
    replicationState.error$.subscribe(async error => {
      console.error('Replication error$:', error);
    });
  }
  private async startReplication() {
    const db = this.getDb();
    this.replicateCollection<UserType>({
      collection: db.users,
      replicationId: '/users/sync',
      api: {
        push: usersApi.push,
        pull: usersApi.pull,
      },
    });
  }
  private setupConnectivityListener() {
    NetInfo.addEventListener(state => {
      const wasOffline = !this.isOnline;
      this.isOnline = Boolean(state.isConnected);
      if (this.isOnline && wasOffline) {
        this.onReconnected();
      }
    });
  }
  private async onReconnected() {
    this.startReplication();
  }
}
При запуске приложения создаем экземпляр RxDatabaseManager.
//App.tsx
useEffect(() => {
  const init = async () => {
    const dbManager = RxDatabaseManager.getInstance();
    dbManager
      .init()
      .then(() => {
        setAppStatus('ready');
      })
      .catch((error) => {
        console.log('Error initializing database:', error);
        setAppStatus('error');
      });
  };
  init();
}, []);

Репликация данных: синхронизация клиента и сервера

Когда приложение переходит из оффлайн состояния в онлайн, мы вызываем метод onReconnected. Он инициирует синхронизацию данных между локальной базой и сервером через replicateRxCollection.
Для этого RxDB передаёт серверу чекпоинт (updatedAt, id) последней успешно полученной записи.
//instance.ts
async handler(checkpointOrNull: unknown, batchSize: number) {
  const typedCheckpoint = checkpointOrNull as CheckPointType;
  const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
  const id = typedCheckpoint ? typedCheckpoint.id : '';
  const response = await api.pull({ updatedAt, id, batchSize });
  return {
    documents: response.data.documents,
    checkpoint: response.data.checkpoint,
  };
},
Сервер возвращает список новых/измененных документов с тех пор (documents) и новый чекпоинт (checkpoint).
//users.query-repository.ts
async pull(dto: PullUsersDto): Promise<UserViewDto[]> {
  const { id, updatedAt, batchSize } = dto;
  const users = await this.users
    .createQueryBuilder('user')
    .where('user.updatedAt > :updatedAt', { updatedAt })
    .orWhere('user.updatedAt = :updatedAt AND user.id > :id', {
      updatedAt,
      id,
    })
    .orderBy('user.updatedAt', 'ASC')
    .addOrderBy('user.id', 'ASC')
    .limit(batchSize)
    .getMany();
  return users.map(UserViewDto.mapToView);
}
//user.service.ts
async pull(dto: PullUsersDto) {
  const users = await this.usersRepository.pull(dto);
  const newCheckpoint =
    users.length === 0
      ? { id: dto.id, updatedAt: dto.updatedAt }
      : {
          id: users.at(-1)!.id,
          updatedAt: users.at(-1)!.updatedAt,
        };
  return {
    documents: users,
    checkpoint: newCheckpoint,
  };
}
RxDB анализирует, какие документы были изменены локально с момента последней синхронизации. Эти изменения передаются в changeRows и отправляются на сервер через push.handler().
//instance.ts
async handler(changeRows) {
  const response = await api.push({ changeRows });
  return response.data;
},
Сервер проверяет каждое изменение и дальше:
  • Применяет его, если нет конфликта;
  • Возвращает конфликт, если updatedAt этого документа на сервере новый.
//user.service.ts
async push(dto: PushUsersDto) {
  const changeRows = dto.changeRows;
  const existingUsers = await this.usersRepository.findByIds(
    changeRows.map((changeRow) => changeRow.newDocumentState.id),
  );
  const existingMap = new Map(existingUsers.map((user) => [user.id, user]));
  const toSave: UserViewDto[] = [];
  const conflicts: UserViewDto[] = [];
  for (const changeRow of changeRows) {
    const newDoc = changeRow.newDocumentState;
    const existing = existingMap.get(newDoc.id);
    const isConflict = existing && existing.updatedAt > newDoc?.updatedAt;
    if (isConflict) {
      conflicts.push(existing);
    } else {
      toSave.push(newDoc);
    }
    if (toSave.length > 0) {
      await this.usersRepository.save(toSave);
    }
  }
  return conflicts;
}

Разрешение конфликтов

Возвращённые конфликты обрабатываются через conflictHandler.resolve() на клиенте.
⚡ В этом примере мы рассматриваем простой способ обработки конфликтов «Last update wins». Обратите внимание, что он может не подойти для некоторых сложных сценариев.
//utills.ts
export const defaultConflictHandler: RxConflictHandler<{
  updatedAt: number;
}> = {
  isEqual(a, b) {
    return a.updatedAt === b.updatedAt;
  },
  resolve({ assumedMasterState, realMasterState, newDocumentState }) {
    return Promise.resolve(realMasterState);
  },
};
После успешной синхронизации, RxDB обновляет внутренние чекпоинты – чтобы при следующей синхронизации отправлять/запрашивать только новые изменения

Использование данных в приложении

Когда база данных проинициализирована, меняем статус приложения на Ready и отображаем UI.
//UsersScreen.tsx
export const UsersScreen = () => {
  const users = useUsersSelector({
    sort: [{ updatedAt: 'desc' }],
  });
  const { createUser, deleteUser, updateUser } = useUsersService();
  return (
    <View style={styles.container}>
      {users.map(user => (
        <Text key={user.id}>{user.name}</Text>
      ))}
      <Button title={'Create new user'} onPress={createUser} />
      <Button
        disabled={users.length === 0}
        title={'Update user'}
        onPress={() => updateUser(users[0].id)}
      />
      <Button
        disabled={users.length === 0}
        title={'Delete user'}
        onPress={() => deleteUser(users[0].id)}
      />
    </View>
  );
};
Внутри useUsersService мы забираем из коллекции пользователей с указанным query- фильтром и подписываемся на обновления полученного результата.
//user.selector.tsx
export const useUsersSelector = (query?: MangoQuery<UserType>) => {
  const userModel = RxDatabaseManager.getInstance().getDb().users;
  const [users, setUsers] = useState<UserType[]>([]);
  useEffect(() => {
    const subscription = userModel.find(query).$.subscribe(result => {
      setUsers(result);
    });
    return () => subscription.unsubscribe();
  }, [userModel, query]);
  return users;
};

Заключение

В этом примере мы показали базовую реализацию local-first архитектуры с синхронизацией данных между клиентом и сервером. Такой подход помогает поддерживать актуальность данных и обеспечивает стабильную работу приложения даже без интернета.
Так, мы использовали этот подход в разработке приложения для курьеров дарк-китчена Sizl в Чикаго. Во многих районах города нестабильная связь, а заказ не будет считаться завершенным, если курьер не отметит это в приложении или не загрузит фото заказа у двери при бесконтактной доставке. Теперь он может выполнить эти действия даже без подключения к интернету, а приложение синхронизирует данные позже.
Sizl: как мы стали техническим партнером сети дарк китченов в Чикаго
История работы над приложением для быстрорастущей сети дарк китченов с доставкой еды и самовывозом, которая началась с ребилда и продолжилась разработкой нового функционала. Сразу после релиза продукт привлёк инвестиции, а сама компания продолжила активно расширять своё присутствие в Чикаго.
При этом в реальных проектах нередко возникают более сложные сценарии: параллельные изменения на нескольких устройствах, удаление связанных данных, работа с большим объемом документов. Для таких случаев требуется дополнительная логика и более гибкая архитектура, которая выходит за рамки базового примера.
Хотите поработать с нашей командой? Расскажите о своем проекте
Читайте также