Offline-first формы: как не потерять данные при обрыве связи
Один проп offline включает сохранение в IndexedDB и автосинхронизацию с retry. Три независимых механизма: persistence, offline и useFormAutosave.
17 мая 2026 г.
--TL;DR:
- Один проп
Кому полезно:
- Junior: понять разницу между persistence (черновики) и offline (гарантия доставки) и когда какой механизм использоватьMiddle: освоить offline prop, SyncQueue, конфликт-стратегии и паттерн комбинирования persistence + offlineSenior: оценить архитектуру retry с backoff, FileUpload в offline-режиме и серверный автосейв для CMS-форм
Девятая статья из цикла «@letar/forms — от боли к декларативным формам». Как формы сохраняют данные локально при потере связи и синхронизируются при восстановлении.
Проблема: заполнил — потерял
Инструктор автошколы заполняет отчёт о занятии на планшете. 15 полей: ученик, маршрут, результаты, замечания. Нажимает «Отправить» — и видит ошибку сети. Данные потеряны. 10 минут работы в пустую.
Или другой сценарий: менеджер в поле заполняет форму заказа. Мобильный интернет нестабилен. Форма зависает на submit — непонятно, отправилось или нет.
Решение: offline prop
<Form
schema={LessonReportSchema}
initialValue={data}
onSubmit={saveLessonReport}
offline={{
actionType: 'SAVE_LESSON_REPORT',
onQueued: () => toast.info('Сохранено локально. Отправится при восстановлении связи.'),
onSynced: () => toast.success('Синхронизировано с сервером!'),
onSyncError: (error) => toast.error(`Ошибка синхронизации: ${error.message}`),
}}
>
<Form.OfflineIndicator />
<Form.Field.String name="studentName" />
<Form.Field.Select name="route" />
<Form.Field.Rating name="result" />
<Form.Field.Textarea name="notes" />
<Form.Button.Submit>Сохранить отчёт</Form.Button.Submit>
</Form>
Что происходит:
- Онлайн: форма отправляется как обычно через
Как это работает
Архитектура
┌─────────────┐ Онлайн? ┌──────────────┐
│ Form Submit │───── Да ──────▶│ onSubmit() │──▶ Сервер
│ │ └──────────────┘
│ │ Нет? ┌──────────────┐
│ │───── Нет ──────▶│ IndexedDB │
└─────────────┘ └──────┬───────┘
│
┌───────────▼──────────┐
│ SyncQueue (фоновый) │
│ Ждёт восстановления │
└───────────┬──────────┘
│ Онлайн!
┌───────────▼──────────┐
│ Retry: onSubmit() │──▶ Сервер
│ 3 попытки, backoff │
└──────────────────────┘
IndexedDB: локальное хранилище
Каждая отправка формы сохраняется как запись:
{
id: 'uuid-1234',
actionType: 'SAVE_LESSON_REPORT',
data: { studentName: 'Иванов', route: 'маршрут-3', ... },
status: 'pending', // pending → syncing → synced | failed
createdAt: '2026-03-31T10:00:00Z',
retryCount: 0,
}
SyncQueue: очередь синхронизации
// Фоновый процесс
window.addEventListener('online', () => {
syncQueue.processAll()
})
// Или по таймеру (для нестабильного соединения)
setInterval(() => {
if (navigator.onLine) {
syncQueue.processAll()
}
}, 30_000) // каждые 30 сек
Retry-стратегия:
- 1-я попытка: сразу2-я: через 5 сек3-я: через 30 секПосле 3 неудач:
OfflineIndicator: UI статуса
<Form.OfflineIndicator />
Показывает:
- Онлайн: ничего (или зелёный индикатор)Оффлайн: жёлтая плашка «Нет соединения. Данные сохраняются локально»Синхронизация: «Отправка 3 из 5...» с прогресс-баромОшибка: красная плашка с кнопкой «Повторить»
Persistence: сохранение черновиков
Отдельная от offline фича — автосохранение черновиков в localStorage:
<Form
schema={Schema}
initialValue={data}
onSubmit={save}
persistence={{
key: 'product-draft', // Ключ в localStorage
debounceMs: 1000, // Сохранять через 1 сек после изменения
onRestore: () => toast.info('Восстановлен черновик'),
}}
>
...
</Form>
Пользователь закрыл вкладку, вернулся — черновик на месте. После успешной отправки черновик удаляется.
Persistence vs Offline
| Аспект | Persistence | Offline |
|---|---|---|
| Хранилище | localStorage | IndexedDB |
| Когда | При вводе (autosave) | При submit без сети |
| Цель | Не потерять черновик | Гарантировать доставку |
| Очередь | Нет | Да, с retry |
Можно комбинировать оба:
<Form
schema={Schema}
initialValue={data}
onSubmit={save}
persistence={{ key: 'order-draft' }}
offline={{ actionType: 'CREATE_ORDER', onSynced: () => toast.success('Заказ создан!') }}
>
Реальный кейс: PWA для автошколы
Driving-school — PWA-приложение для автошкол. Инструкторы работают на планшетах, часто без стабильного интернета:
function LessonReportForm({ lesson, students }) {
return (
<Form
schema={LessonReportSchema}
initialValue={{ lessonId: lesson.id, date: new Date() }}
onSubmit={saveLessonReport}
persistence={{ key: `lesson-${lesson.id}` }}
offline={{
actionType: 'SAVE_LESSON_REPORT',
onQueued: () => toast.info('Отчёт сохранён локально'),
onSynced: () => toast.success('Отчёт отправлен'),
}}
>
<Form.OfflineIndicator />
<Form.Field.Select name="studentId" options={students} />
<Form.Field.Date name="date" />
<Form.Field.Duration name="duration" />
<Form.Field.Select name="route" />
{/* Form.Steps — подробнее в статье 5: мультистеп */}
<Form.Steps animated>
<Form.Steps.Step title="Оценка">
<Form.Field.Rating name="theory" />
<Form.Field.Rating name="practice" />
<Form.Field.Rating name="safety" />
</Form.Steps.Step>
<Form.Steps.Step title="Замечания">
<Form.Field.Textarea name="notes" />
<Form.Field.FileUpload name="photos" accept="image/*" />
</Form.Steps.Step>
<Form.Steps.Navigation submitLabel="Сохранить отчёт" />
</Form.Steps>
</Form>
)
}
Инструктор заполняет отчёт → интернет пропал → данные в IndexedDB → связь вернулась → автосинхронизация → toast «Отчёт отправлен».
Edge-кейсы: конфликты, файлы и лимиты
Конфликты при синхронизации
Пользователь заполнил форму оффлайн, а за это время запись на сервере изменилась. Что делать?
offline={{
actionType: 'UPDATE_LESSON_REPORT',
conflictStrategy: 'last-write-wins', // по умолчанию
onConflict: (local, server) => {
// Кастомная стратегия: показать диалог
return showConflictDialog(local, server)
},
}}
Три стратегии разрешения:
| Стратегия | Описание |
|---|---|
| Последнее сохранение побеждает (по умолчанию) |
| Серверная версия всегда приоритетнее |
| Вызывается |
Для большинства форм (создание записей) конфликтов не бывает. Для редактирования — рекомендуется
FileUpload в offline-режиме
Файлы — особый случай. Бинарные данные больше текстовых полей на порядки:
// При offline submit файлы сохраняются как Blob в IndexedDB
{
id: 'uuid-5678',
actionType: 'SAVE_REPORT',
data: { studentName: 'Иванов', notes: '...' },
files: [
{ fieldName: 'photos', blob: Blob(245000), fileName: 'photo1.jpg' },
{ fieldName: 'photos', blob: Blob(312000), fileName: 'photo2.jpg' },
],
status: 'pending',
}
При синхронизации файлы отправляются через
- Сначала отправляются файлы → получаем URLЗатем отправляются данные формы с URL файловЕсли файл не удалось отправить — запись остаётся в очереди
Ограничение: очень большие файлы (>50 МБ) могут не поместиться в IndexedDB. Для таких случаев рекомендуется ограничение
Лимиты IndexedDB
| Браузер | Лимит | Как узнать |
|---|---|---|
| Chrome | До 80% свободного места | |
| Firefox | До 50% свободного места | |
| Safari | ~1 ГБ (может запросить увеличение) | Нет API |
На практике: 1000 текстовых форм занимают ~5 МБ. Проблемы начинаются только с файлами.
Библиотека автоматически:
- Удаляет записи со статусом
Мониторинг очереди
import { useSyncQueue } from '@letar/forms/offline'
function SyncStatus() {
const { pending, syncing, failed, total } = useSyncQueue()
return (
<Text fontSize="sm" color="gray.500">
В очереди: {pending} | Отправляется: {syncing} | Ошибки: {failed}
</Text>
)
}
Серверный автосейв
Для длинных форм (20+ полей) данные должны сохраняться на сервер автоматически, не дожидаясь submit:
import { AutosaveIndicator, useFormAutosave } from '@letar/forms'
function EditProductForm({ productId }: { productId: string }) {
return (
<Form schema={ProductSchema} initialValue={product} onSubmit={save}>
<AutosaveIndicator /> {/* «Сохранено» / «Сохраняется...» / «Ошибка» */}
<Form.Field.String name="title" />
<Form.Field.RichText name="description" />
<Form.Field.Currency name="price" />
<Form.Button.Submit>Опубликовать</Form.Button.Submit>
</Form>
)
}
Form.DirtyGuard — предупреждение при уходе
<Form schema={Schema} initialValue={data} onSubmit={save}>
<Form.DirtyGuard message="У вас есть несохранённые изменения. Уйти?" />
<Form.Field.String name="title" />
<Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>
Сравнение подходов
| Функция | | | |
|---|---|---|---|
| Хранилище | localStorage | IndexedDB | Сервер (API endpoint) |
| Когда | При каждом изменении | При submit без сети | По таймеру / debounce |
| Восстановление | При перезагрузке страницы | При восстановлении сети | Не нужно (уже на сервере) |
| Для чего | Черновики | Гарантия доставки | Длинные формы (CMS) |
Итоги
| Компонент | Что делает |
|---|---|
| Включает offline-режим с очередью |
| Автосохранение черновиков |
| Серверный автосейв по таймеру |
| UI-статус автосохранения |
| Предупреждение о несохранённых данных |
| UI-статус сети и синхронизации |
| Тип действия (для идентификации в очереди) |
| Колбэки жизненного цикла |
Ключевые принципы:
- Форма не знает про сеть — offline/online прозрачно для пользователяГарантия доставки — retry с exponential backoffPersistence отдельно — черновики ≠ offline-очередьАвтосейв — для длинных форм, где данные критичны
Ссылки
- Документация: forms.letar.bestПримеры: forms-example.letar.bestGitHub: github.com/letar/formsnpm MCP:
Это девятая статья из цикла «@letar/forms — от боли к декларативным формам». Предыдущая: ZenStack pipeline | Следующая: i18n.
Как вы решаете offline-сценарии?