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

Представьте курьера, который не может открыть маршрут из-за плохой связи, или менеджера склада, который не видит список товаров, потому что сервер недоступен. Такие сбои могут замедлить бизнес-процессы, сорвать сроки и привести к убыткам. Поэтому в некоторых сценариях мобильные приложения должны продолжать работу даже без доступа к интернету.
У многих наших клиентов бывают проблемы со связью – на удалённых объектах, выездных мероприятиях, в логистике или на складах. В таких условиях важно, чтобы данные на устройстве оставались доступны, а все изменения автоматически подтягивались на сервер, когда интернет снова появится.
Такие приложения строятся по принципу 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() на клиенте.
//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 в Чикаго. Во многих районах города нестабильная связь, а заказ не будет считаться завершенным, если курьер не отметит это в приложении или не загрузит фото заказа у двери при бесконтактной доставке. Теперь он может выполнить эти действия даже без подключения к интернету, а приложение синхронизирует данные позже.

При этом в реальных проектах нередко возникают более сложные сценарии: параллельные изменения на нескольких устройствах, удаление связанных данных, работа с большим объемом документов. Для таких случаев требуется дополнительная логика и более гибкая архитектура, которая выходит за рамки базового примера.