Формы как state manager: фильтры, URL-синхронизация, панели настроек
Форма — это не только «заполни и отправь». Form без onSubmit как state container для фильтров и настроек. Form.Subscribe, Form.UrlSync, useFormRef и useActiveFiltersCount.
22 мая 2026 г.
--TL;DR:
- Форма — это не только «заполни и отправь». Используй
Кому полезно:
- Junior: научиться использовать форму без кнопки «Отправить» и понять, когда это оправданоMiddle: освоить паттерны фильтров с URL-синхронизацией, debounce и внешним сбросомSenior: оценить архитектуру «форма как единственный источник истины для UI-стейта» и сравнить с альтернативами
Четырнадцатая статья из цикла «@letar/forms — от боли к декларативным формам». Формы решают не только задачу сбора данных — они отлично подходят для управления состоянием интерфейса.
Проблема: state management своими руками
Страница каталога товаров. Фильтры: поиск, категория, ценовой диапазон, статус. Стандартный подход:
// Типичный код управления фильтрами
const [search, setSearch] = useState('')
const [category, setCategory] = useState('all')
const [minPrice, setMinPrice] = useState(0)
const [maxPrice, setMaxPrice] = useState(100000)
const [status, setStatus] = useState<string[]>([])
// URL-синхронизация — ещё 30 строк
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (category !== 'all') params.set('category', category)
// ...
router.replace(`?${params}`)
}, [search, category, minPrice, maxPrice, status])
// Инициализация из URL — ещё 20 строк
useEffect(() => {
const params = new URLSearchParams(window.location.search)
setSearch(params.get('search') ?? '')
setCategory(params.get('category') ?? 'all')
// ...
}, [])
// Сброс — ещё 10 строк
function resetFilters() {
setSearch('')
setCategory('all')
setMinPrice(0)
setMaxPrice(100000)
setStatus([])
}
80 строк на логику, которая не относится к бизнесу. И это без валидации и типизации.
Решение: форма без submit
TanStack Form (ядро
import { FilterSchema } from './_schemas/filter.schema'
const defaultFilters = {
search: '',
category: 'all',
minPrice: 0,
maxPrice: 100000,
status: [] as string[],
}
function CatalogPage() {
return (
<Form
schema={FilterSchema}
initialValue={defaultFilters}
onSubmit={async () => {}} // форма без реального submit
>
<HStack mb={4}>
<Form.Field.String name="search" placeholder="Поиск..." />
<Form.Select.Category name="category" />
<Form.Field.RangeSlider name="minPrice" min={0} max={100000} />
<Form.Field.MultiSelect name="status" options={statusOptions} />
<Form.Button.Reset>Сбросить</Form.Button.Reset>
</HStack>
{/* ProductList получает актуальные фильтры при каждом изменении */}
<Form.Subscribe>
{(filters) => <ProductList filters={filters} />}
</Form.Subscribe>
</Form>
)
}
Что изменилось: нет
Form.Subscribe: реактивная подписка
<Form.Subscribe>
{(values) => (
<Text>
Найдено товаров в категории «{values.category}»: {/* запрос к API */}
</Text>
)}
</Form.Subscribe>
Для подписки на отдельные поля —
function ActiveFiltersBar() {
const { value } = useTypedFormSubscribe(['search', 'category', 'status'])
const activeCount = [
value.search !== '',
value.category !== 'all',
value.status.length > 0,
].filter(Boolean).length
if (activeCount === 0) return null
return (
<Badge colorScheme="blue">
Фильтры активны: {activeCount}
</Badge>
)
}
Этот компонент живёт вне дерева
Debounce для поиска
Текстовый поиск не должен стрелять запросом на каждую букву. Паттерн с
function CatalogPage() {
const [debouncedSearch, setDebouncedSearch] = useState('')
return (
<Form schema={FilterSchema} initialValue={defaultFilters} onSubmit={async () => {}}>
<Form.Field.String name="search" placeholder="Поиск..." />
{/* Дебаунс через Form.Watch */}
<Form.Watch
field="search"
onChange={(value) => {
clearTimeout((window as any).__searchTimer)
;(window as any).__searchTimer = setTimeout(() => {
setDebouncedSearch(String(value))
}, 300)
}}
/>
<Form.Subscribe>
{(filters) => <ProductList filters={{ ...filters, search: debouncedSearch }} />}
</Form.Subscribe>
</Form>
)
}
Планируется:
URL-синхронизация: персистентные фильтры
Фильтры должны сохраняться в URL — чтобы страница пережила перезагрузку и пользователь мог поделиться ссылкой.
Инициализация из URL
import { useUrlPrefill } from '@letar/forms'
function CatalogPage() {
const prefilled = useUrlPrefill({
fields: ['search', 'category', 'minPrice', 'maxPrice', 'status'],
schema: FilterSchema, // валидация значений из URL
})
return (
<Form
schema={FilterSchema}
initialValue={{ ...defaultFilters, ...prefilled }}
onSubmit={async () => {}}
>
{/* ... */}
</Form>
)
}
Запись в URL при изменении
Сейчас запись в URL делается вручную через
import { useRouter } from 'next/navigation'
function FilterUrlSync() {
const router = useRouter()
return (
<Form.Subscribe>
{(values) => {
// Синхронизируем URL при изменении фильтров
const params = new URLSearchParams()
if (values.search) params.set('search', String(values.search))
if (values.category !== 'all') params.set('category', String(values.category))
if (values.minPrice !== 0) params.set('minPrice', String(values.minPrice))
if (values.status?.length) {
;(values.status as string[]).forEach((s) => params.append('status', s))
}
// Используем useEffect через render trick
useEffect(() => {
router.replace(`?${params}`, { scroll: false })
}, [params.toString()])
return null
}}
</Form.Subscribe>
)
}
// Использование:
<Form schema={FilterSchema} initialValue={{ ...defaultFilters, ...prefilled }} onSubmit={async () => {}}>
<FilterUrlSync />
{/* поля */}
</Form>
Планируется:
Панель настроек с Apply/Cancel
Другой паттерн: пользователь меняет настройки, нажимает «Применить» — изменения вступают в силу. «Отмена» — всё откатывается к предыдущему состоянию.
interface AppSettings {
theme: 'light' | 'dark' | 'system'
language: 'ru' | 'en'
notifications: boolean
itemsPerPage: number
}
function SettingsPanel({ settings, onSave }: {
settings: AppSettings
onSave: (s: AppSettings) => void
}) {
return (
<Form
schema={SettingsSchema}
initialValue={settings}
onSubmit={async (values) => {
// Здесь submit нужен — применяем настройки
await onSave(values)
toast.success('Настройки сохранены')
}}
>
<Form.Field.NativeSelect
name="theme"
label="Тема"
options={[
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Системная' },
]}
/>
<Form.Field.NativeSelect
name="language"
label="Язык"
options={[{ value: 'ru', label: 'Русский' }, { value: 'en', label: 'English' }]}
/>
<Form.Field.Switch name="notifications" label="Уведомления" />
<Form.Field.Number name="itemsPerPage" label="Элементов на странице" min={10} max={100} step={10} />
{/* isDirty показывает, есть ли несохранённые изменения */}
<Form.Subscribe>
{(_, formState) => (
<HStack mt={4}>
<Form.Button.Submit isDisabled={!formState.isDirty}>
Применить
</Form.Button.Submit>
<Form.Button.Reset isDisabled={!formState.isDirty}>
Отмена
</Form.Button.Reset>
</HStack>
)}
</Form.Subscribe>
</Form>
)
}
Dashboard-контролы
Форма хорошо подходит для управления dashboard: выбор периода, группировки, метрик.
const DashboardControls = z.object({
period: z.enum(['day', 'week', 'month', 'quarter', 'year']),
groupBy: z.enum(['day', 'week', 'month']),
metrics: z.array(z.enum(['revenue', 'orders', 'users', 'conversion'])),
compareWith: z.enum(['previous_period', 'previous_year', 'none']),
})
function DashboardPage() {
const prefilled = useUrlPrefill({
fields: ['period', 'groupBy', 'metrics', 'compareWith'],
schema: DashboardControls,
})
return (
<Form
schema={DashboardControls}
initialValue={{ period: 'month', groupBy: 'day', metrics: ['revenue'], compareWith: 'none', ...prefilled }}
onSubmit={async () => {}}
>
<HStack wrap="wrap" mb={6}>
<Form.Field.SegmentedControl
name="period"
options={periodOptions}
/>
<Form.Field.NativeSelect name="groupBy" label="Группировать по" options={groupByOptions} />
<Form.Field.CheckboxGroup name="metrics" options={metricOptions} />
<Form.Field.NativeSelect name="compareWith" label="Сравнить с" options={compareOptions} />
</HStack>
<Form.Subscribe>
{(controls) => <DashboardCharts controls={controls} />}
</Form.Subscribe>
</Form>
)
}
Все контролы dashboard в одном стейте, URL сериализуется автоматически, можно передавать ссылку с конкретным видом.
Форма vs useState: когда что выбрать
| Сценарий | Рекомендация |
|---|---|
| 1-2 независимых фильтра | |
| 3+ взаимосвязанных фильтра | |
| Фильтры + URL-синхронизация | |
| Настройки с Apply/Cancel | |
| Dashboard-контролы | |
| Поисковая строка (только текст) | |
Ключевой вопрос: нужна ли валидация, сброс к дефолту или URL-синхронизация? Если хотя бы одно — форма оправдана.
Что появится в следующих версиях
Мы описали несколько паттернов, которые сейчас требуют ручного кода. Они войдут в ближайшие релизы:
Итог
Форма — это не только UI для ввода данных. Это state machine с валидацией, подпиской, сбросом и историей. Когда у вас 3+ взаимосвязанных элемента управления — рассмотрите
- Единый источник истины — все контролы в одном стейтеБесплатный сброс —