Forms as State Manager
Use Form without submit — filters, URL sync, settings panels, dashboard controls
Full example
The complete sandbox example, read directly from the form-develop-app source at build time —
switch framework/skin to see Form used as a submit-less state container for filters. No
shadcn/Vue/Angular equivalent exists yet — those tabs show as disabled.
'use client'
import { Badge, Box, Button, Card, Grid, Heading, HStack, SimpleGrid, Tag, Text, VStack } from '@chakra-ui/react'
import { Form, getActiveUrlSyncFields, useActiveFiltersCount, useFormRef, useFormUrlSync } from '@letar/forms'
import { z } from 'zod/v4'
// --- Схема фильтров ---
const FiltersSchema = z.object({
search: z.string().meta({ ui: { title: 'Поиск', placeholder: 'Введите название...' } }),
category: z.enum(['all', 'frontend', 'backend', 'devops']).meta({ ui: { title: 'Категория' } }),
minRating: z
.number()
.min(0)
.max(5)
.meta({ ui: { title: 'Минимальный рейтинг' } }),
tags: z.array(z.string()).meta({ ui: { title: 'Теги' } }),
onlyFavorites: z.boolean().meta({ ui: { title: 'Только избранные' } }),
})
type Filters = z.infer<typeof FiltersSchema>
const defaultFilters: Filters = {
search: '',
category: 'all',
minRating: 0,
tags: [],
onlyFavorites: false,
}
// --- Демо-данные ---
const allItems = [
{ id: 1, title: 'React', category: 'frontend', rating: 5, tags: ['ui', 'jsx'], favorite: true },
{ id: 2, title: 'TypeScript', category: 'frontend', rating: 5, tags: ['types', 'js'], favorite: true },
{ id: 3, title: 'Node.js', category: 'backend', rating: 4, tags: ['js', 'server'], favorite: false },
{ id: 4, title: 'PostgreSQL', category: 'backend', rating: 4, tags: ['sql', 'database'], favorite: true },
{ id: 5, title: 'Docker', category: 'devops', rating: 4, tags: ['containers', 'devops'], favorite: false },
{ id: 6, title: 'Nginx', category: 'devops', rating: 3, tags: ['server', 'proxy'], favorite: false },
{ id: 7, title: 'Vue.js', category: 'frontend', rating: 4, tags: ['ui', 'framework'], favorite: false },
{ id: 8, title: 'Prisma', category: 'backend', rating: 5, tags: ['orm', 'database'], favorite: true },
]
// --- Секция с результатами (использует Form.Subscribe) ---
function FilteredResults() {
return (
<Form.Subscribe debounce={150}>
{(values) => {
const filters = values as unknown as Filters
const results = allItems.filter((item) => {
if (filters.search && !item.title.toLowerCase().includes(filters.search.toLowerCase())) {
return false
}
if (filters.category !== 'all' && item.category !== filters.category) {
return false
}
if (item.rating < filters.minRating) {
return false
}
if (filters.onlyFavorites && !item.favorite) {
return false
}
if (filters.tags.length > 0 && !filters.tags.some((tag) => item.tags.includes(tag))) {
return false
}
return true
})
return (
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="md">Результаты</Heading>
<Badge colorPalette={results.length > 0 ? 'green' : 'gray'}>
{results.length} из {allItems.length}
</Badge>
</HStack>
{results.length === 0
? (
<Box py={8} textAlign="center" color="gray.500">
Ничего не найдено. Измените фильтры.
</Box>
)
: (
<SimpleGrid columns={{ base: 1, sm: 2 }} gap={3}>
{results.map((item) => (
<Card.Root key={item.id} variant="outline" size="sm">
<Card.Body>
<HStack justify="space-between">
<Text fontWeight="medium">{item.title}</Text>
{item.favorite && <Text color="yellow.500">★</Text>}
</HStack>
<HStack gap={1} mt={2} wrap="wrap">
<Badge colorPalette="blue" size="sm">
{item.category}
</Badge>
{'★'
.repeat(item.rating)
.split('')
.map((s, i) => (
<Text key={i} color="yellow.400" fontSize="xs">
{s}
</Text>
))}
</HStack>
<HStack gap={1} mt={1} wrap="wrap">
{item.tags.map((tag) => (
<Tag.Root key={tag} size="sm" variant="subtle">
<Tag.Label>{tag}</Tag.Label>
</Tag.Root>
))}
</HStack>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
)}
</Box>
)
}}
</Form.Subscribe>
)
}
// --- Счётчик активных фильтров ---
function ActiveFiltersCount() {
const count = useActiveFiltersCount(defaultFilters)
if (count === 0) {
return null
}
return (
<Badge colorPalette="red" borderRadius="full">
{count}
</Badge>
)
}
// --- Чипы активных фильтров (использует getActiveUrlSyncFields) ---
function ActiveFilterChips({ formRef }: { formRef: ReturnType<typeof useFormRef<Filters>> }) {
return (
<Form.Subscribe debounce={150}>
{(values) => {
const filters = values as unknown as Filters
const active = getActiveUrlSyncFields(
filters,
['search', 'category', 'minRating', 'onlyFavorites'],
defaultFilters,
)
if (active.length === 0) {
return null
}
return (
<HStack wrap="wrap" gap={2} data-testid="active-filter-chips">
{active.map(({ field }) => (
<Tag.Root key={field} size="sm" variant="subtle" colorPalette="purple">
<Tag.Label>{field}</Tag.Label>
<Button
size="2xs"
variant="ghost"
minW="auto"
h="auto"
p={0}
ml={1}
onClick={() => formRef.current?.setFieldValue(field, defaultFilters[field] as never)}
aria-label={`Сбросить ${field}`}
>
✕
</Button>
</Tag.Root>
))}
</HStack>
)
}}
</Form.Subscribe>
)
}
// --- Панель внешнего управления (использует useFormRef) ---
function ExternalControls({ formRef }: { formRef: ReturnType<typeof useFormRef<Filters>> }) {
const presets: Array<{ label: string; values: Partial<Filters> }> = [
{ label: 'Frontend топ', values: { category: 'frontend', minRating: 4 } },
{ label: 'Избранные', values: { onlyFavorites: true } },
{ label: 'Database', values: { tags: ['database'] } },
]
return (
<VStack align="stretch" gap={2}>
<Text fontSize="sm" fontWeight="medium" color="gray.500">
Быстрые пресеты (useFormRef):
</Text>
<HStack wrap="wrap" gap={2}>
{presets.map((preset) => (
<Button
key={preset.label}
size="xs"
variant="outline"
colorPalette="purple"
onClick={() => {
if (!formRef.current) {
return
}
// Сбрасываем до дефолтов, потом применяем пресет
const form = formRef.current
Object.entries(defaultFilters).forEach(([key, val]) => {
form.setFieldValue(key as keyof Filters, val as never)
})
Object.entries(preset.values).forEach(([key, val]) => {
form.setFieldValue(key as keyof Filters, val as never)
})
}}
>
{preset.label}
</Button>
))}
</HStack>
</VStack>
)
}
// --- Главная страница ---
export default function FiltersStateDemoPage() {
// useFormUrlSync читает начальные значения из URL
const { initialValue } = useFormUrlSync({
fields: ['search', 'category', 'minRating', 'onlyFavorites'],
defaults: defaultFilters,
debounce: 400,
})
// useFormRef для доступа к form API снаружи компонента Form
const formRef = useFormRef<Filters>()
return (
<Box p={8} maxW="1200px" mx="auto">
<VStack gap={6} align="stretch">
<Box>
<Heading mb={2}>Filters State Demo</Heading>
<Text color="gray.600" _dark={{ color: 'gray.400' }}>
Form как менеджер состояния фильтров. Без onSubmit, с URL-синхронизацией.
<br />
Компоненты: <Badge>Form.Subscribe</Badge> · <Badge>Form.UrlSync</Badge> · <Badge>useFormRef</Badge> ·{' '}
<Badge>useActiveFiltersCount</Badge> · <Badge>getActiveUrlSyncFields</Badge>
</Text>
</Box>
<Form initialValue={initialValue} schema={FiltersSchema} formRef={formRef}>
{/* Form.UrlSync: записывает фильтры в URL с дебаунсом */}
<Form.UrlSync
fields={['search', 'category', 'minRating', 'onlyFavorites']}
defaults={defaultFilters}
debounce={400}
/>
<Grid templateColumns={{ base: '1fr', lg: '280px 1fr' }} gap={6}>
{/* Панель фильтров */}
<Card.Root>
<Card.Header>
<HStack justify="space-between">
<Card.Title>Фильтры</Card.Title>
<ActiveFiltersCount />
</HStack>
</Card.Header>
<Card.Body>
<VStack gap={4} align="stretch">
<ActiveFilterChips formRef={formRef} />
<Form.Field.String name="search" />
<Form.Field.Select
name="category"
options={[
{ label: 'Все категории', value: 'all' },
{ label: 'Frontend', value: 'frontend' },
{ label: 'Backend', value: 'backend' },
{ label: 'DevOps', value: 'devops' },
]}
/>
<Form.Field.Slider
name="minRating"
min={0}
max={5}
step={1}
showValue
marks={[0, 1, 2, 3, 4, 5]}
colorPalette="yellow"
/>
<Form.Field.Checkbox name="onlyFavorites" />
<Form.Button.Reset colorPalette="gray" variant="ghost">
Сбросить фильтры
</Form.Button.Reset>
<ExternalControls formRef={formRef} />
</VStack>
</Card.Body>
</Card.Root>
{/* Результаты через Form.Subscribe */}
<Box>
<FilteredResults />
</Box>
</Grid>
</Form>
{/* Пример кода */}
<Box mt={4}>
<Heading size="md" mb={4}>
Как это работает
</Heading>
<Box
as="pre"
p={4}
bg="gray.900"
color="gray.100"
borderRadius="md"
fontSize="xs"
overflow="auto"
whiteSpace="pre-wrap"
>
{`// 1. Читаем начальные значения из URL
const { initialValue } = useFormUrlSync({
fields: ['search', 'category', 'minRating'],
defaults: defaultFilters,
})
// 2. Получаем ref для внешнего управления
const formRef = useFormRef<Filters>()
// 3. Форма без onSubmit — только state management
<Form initialValue={initialValue} schema={FiltersSchema} formRef={formRef}>
{/* Синхронизируем изменения обратно в URL */}
<Form.UrlSync
fields={['search', 'category', 'minRating']}
defaults={defaultFilters}
debounce={400}
/>
{/* Поля фильтров */}
<Form.Field.String name="search" />
{/* Подписываемся на значения без ре-рендера родителя */}
<Form.Subscribe debounce={150}>
{(values) => <FilteredResults filters={values} />}
</Form.Subscribe>
</Form>
// 4. Счётчик активных фильтров — вне Form
function ActiveCount() {
const count = useActiveFiltersCount(defaultFilters)
return <Badge>{count}</Badge>
}
// 5. Чипы активных фильтров с крестиком-сбросом (getActiveUrlSyncFields)
const active = getActiveUrlSyncFields(values, ['search', 'category'], defaultFilters)
active.map(({ field }) => (
<Tag key={field}>
{field}
<CloseButton onClick={() => formRef.current?.setFieldValue(field, defaultFilters[field])} />
</Tag>
))
// 6. Внешнее управление через ref
formRef.current?.setFieldValue('category', 'frontend')`}
</Box>
</Box>
</VStack>
</Box>
)
}shadcn-вариант этого примера ещё не готов.
Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.
Overview
A form doesn't have to end with a submit button. Form is a subscription-based state machine — it works equally well as a state container for filters, settings panels, and dashboard controls.
When to prefer Form over useState:
| Need | Use |
|---|---|
| 1–2 independent controls | useState — simpler |
| 3+ related controls | Form — single state, free reset |
| Controls + URL sync | Form + useUrlPrefill |
| Settings with Apply/Cancel | Form — isDirty + reset() built in |
| Dashboard controls | Form — validation + URL + single source |
Filter Form (No Submit)
import { z } from 'zod/v4'
const FilterSchema = z
.object({
search: z.string(),
category: z.string(),
minPrice: z.number(),
status: z.array(z.string()),
})
.strip()
const defaults = { search: '', category: 'all', minPrice: 0, status: [] }
function CatalogPage() {
return (
<Form schema={FilterSchema} initialValue={defaults} onSubmit={async () => {}}>
<HStack mb={4}>
<Form.Field.String name="search" placeholder="Search..." />
<Form.Field.NativeSelect name="category" options={categoryOptions} />
<Form.Field.Slider name="minPrice" min={0} max={100000} />
<Form.Field.Listbox name="status" options={statusOptions} selectionMode="multiple" />
<Form.Button.Reset>Reset</Form.Button.Reset>
</HStack>
<Form.Subscribe>{(filters) => <ProductList filters={filters} />}</Form.Subscribe>
</Form>
)
}Form.Button.Reset resets to initialValue — no extra handler needed.
Form.Subscribe
Render prop that re-renders whenever any form value changes:
<Form.Subscribe>{(values) => <ResultCount filters={values} />}</Form.Subscribe>For subscribing to specific fields only — use useTypedFormSubscribe. The component re-renders only when those fields change:
function ActiveFiltersBar() {
const { value } = useTypedFormSubscribe(['search', 'category', 'status'])
const count = [value.search !== '', value.category !== 'all', (value.status as string[]).length > 0].filter(
Boolean
).length
if (count === 0) return null
return <Badge>Active filters: {count}</Badge>
}ActiveFiltersBar can live anywhere in the component tree — it reads form state through context.
Debounced Search
Use Form.Watch to debounce a text field before triggering data fetches:
function CatalogPage() {
const [debouncedSearch, setDebouncedSearch] = useState('')
return (
<Form schema={FilterSchema} initialValue={defaults} onSubmit={async () => {}}>
<Form.Field.String name="search" placeholder="Search..." />
<Form.Watch
field="search"
onChange={(value) => {
clearTimeout((window as any).__t)
;(window as any).__t = setTimeout(() => setDebouncedSearch(String(value)), 300)
}}
/>
<Form.Subscribe>{(filters) => <ProductList filters={{ ...filters, search: debouncedSearch }} />}</Form.Subscribe>
</Form>
)
}URL Sync: Persistent Filters
Filters should survive page reload and be shareable via link.
Read from URL on mount
import { useUrlPrefill } from '@letar/forms'
function CatalogPage() {
const prefilled = useUrlPrefill({
fields: ['search', 'category', 'minPrice', 'status'],
schema: FilterSchema, // validates URL values against schema
})
return (
<Form schema={FilterSchema} initialValue={{ ...defaults, ...prefilled }} onSubmit={async () => {}}>
{/* ... */}
</Form>
)
}Write back to URL on change
import { useRouter } from 'next/navigation'
function FilterUrlSync() {
const router = useRouter()
return (
<Form.Subscribe>
{(values) => {
useEffect(() => {
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))
;(values.status as string[]).forEach((s) => params.append('status', s))
router.replace(`?${params}`, { scroll: false })
})
return null
}}
</Form.Subscribe>
)
}
// Place inside <Form>:
;<FilterUrlSync />Settings Panel with Apply / Cancel
function SettingsPanel({ settings, onSave }) {
return (
<Form
schema={SettingsSchema}
initialValue={settings}
onSubmit={async (values) => {
await onSave(values)
toast.success('Settings saved')
}}
>
<Form.Field.NativeSelect name="theme" label="Theme" options={themeOptions} />
<Form.Field.NativeSelect name="language" label="Language" options={langOptions} />
<Form.Field.Switch name="notifications" label="Notifications" />
<Form.Field.Number name="itemsPerPage" label="Items per page" min={10} max={100} step={10} />
<Form.Subscribe>
{(_, state) => (
<HStack mt={4}>
<Form.Button.Submit isDisabled={!state.isDirty}>Apply</Form.Button.Submit>
<Form.Button.Reset isDisabled={!state.isDirty}>Cancel</Form.Button.Reset>
</HStack>
)}
</Form.Subscribe>
</Form>
)
}isDirty is true when the current values differ from initialValue. Form.Button.Reset rolls back to initialValue — the settings as they were when the panel opened.
Dashboard Controls
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'])),
})
.strip()
function DashboardPage() {
const prefilled = useUrlPrefill({
fields: ['period', 'groupBy', 'metrics'],
schema: DashboardControls,
})
return (
<Form
schema={DashboardControls}
initialValue={{ period: 'month', groupBy: 'day', metrics: ['revenue'], ...prefilled }}
onSubmit={async () => {}}
>
<HStack mb={6}>
<Form.Field.SegmentedGroup name="period" options={periodOptions} />
<Form.Field.NativeSelect name="groupBy" label="Group by" options={groupByOptions} />
<Form.Field.Listbox name="metrics" options={metricOptions} selectionMode="multiple" />
</HStack>
<Form.Subscribe>{(controls) => <DashboardCharts controls={controls} />}</Form.Subscribe>
</Form>
)
}Active Filters as Chips
useActiveFiltersCount gives you a number. When you need the actual list of active fields —
for a "Clear all" button and a per-value ✕ chip — use getActiveUrlSyncFields:
import { getActiveUrlSyncFields } from '@letar/forms'
<Form.Subscribe debounce={150}>
{(values) => {
const active = getActiveUrlSyncFields(values, ['search', 'category', 'minPrice'], defaults)
if (active.length === 0) return null
return (
<HStack wrap="wrap">
{active.map(({ field }) => (
<Tag key={field}>
{field}
<CloseButton onClick={() => formRef.current?.setFieldValue(field, defaults[field])} />
</Tag>
))}
</HStack>
)
}}
</Form.Subscribe>It reuses the same diff Form.UrlSync computes internally before writing to the URL — no need
to re-derive "is this field non-default" at each call site.
Related
- Controlled State — live previews and external control
- URL Prefill — initialize form from URL parameters
- Field Watchers — react to field changes
- TanStack Query — combine filters with data fetching