Calculated Fields
Automatically computed fields that update when dependent fields change
Full example
The complete sandbox example, read directly from the form-develop-app / form-develop-app-shadcn
source at build time. The two sources already diverged in details (calculated-demo in
form-develop-app vs the "Calculated" scenario inside numeric-demo in the shadcn app) — both
demonstrate Form.Field.Calculated/FieldCalculated, but not the identical scenario. No Vue/Angular
equivalent exists yet — those tabs show as disabled.
'use client'
import { Box, Code, Heading, Text, VStack } from '@chakra-ui/react'
import { formatRubles } from '@letar/format-utils'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { DemoPageLayout } from '../_components'
export default function CalculatedDemoPage() {
const [submittedData, setSubmittedData] = useState<Record<string, unknown> | null>(null)
return (
<DemoPageLayout
title="Form.Field.Calculated"
description="Вычисляемые поля — автоматический пересчёт при изменении зависимых полей"
>
<VStack gap={8} align="stretch">
{/* Пример 1: Калькулятор заказа */}
<Box>
<Heading size="md" mb={3}>
Калькулятор заказа
</Heading>
<Text fontSize="sm" color="fg.muted" mb={4}>
Итого = Цена × Количество. С format для валюты.
</Text>
<Form
debug
initialValue={{ price: 1500, qty: 3, total: 0 }}
onSubmit={(data) => setSubmittedData(data as Record<string, unknown>)}
>
<Form.Field.Number name="price" label="Цена" />
<Form.Field.Number name="qty" label="Количество" />
<Form.Field.Calculated
name="total"
label="Итого"
compute={(v) => (Number(v.price) || 0) * (Number(v.qty) || 0)}
format={(v) => formatRubles(Number(v))}
deps={['price', 'qty']}
/>
<Form.Button.Submit>Отправить</Form.Button.Submit>
</Form>
</Box>
{/* Пример 2: Скидка + Финальная цена */}
<Box>
<Heading size="md" mb={3}>
Каскадные вычисления
</Heading>
<Text fontSize="sm" color="fg.muted" mb={4}>
Скидка применяется к итого. Два Calculated поля зависят друг от друга через цепочку.
</Text>
<Form
debug
initialValue={{ price: 2000, qty: 2, discount: 10, subtotal: 0, finalPrice: 0 }}
onSubmit={(data) => setSubmittedData(data as Record<string, unknown>)}
>
<Form.Field.Number name="price" label="Цена за единицу" />
<Form.Field.Number name="qty" label="Количество" />
<Form.Field.Calculated
name="subtotal"
label="Подытого"
compute={(v) => (Number(v.price) || 0) * (Number(v.qty) || 0)}
format={(v) => formatRubles(Number(v))}
deps={['price', 'qty']}
/>
<Form.Field.Number name="discount" label="Скидка (%)" />
<Form.Field.Calculated
name="finalPrice"
label="Итого со скидкой"
compute={(v) => {
const sub = (Number(v.price) || 0) * (Number(v.qty) || 0)
const disc = Number(v.discount) || 0
return sub * (1 - disc / 100)
}}
format={(v) => formatRubles(Number(v))}
deps={['price', 'qty', 'discount']}
/>
<Form.Button.Submit>Оформить</Form.Button.Submit>
</Form>
</Box>
{/* Пример 3: Hidden mode */}
<Box>
<Heading size="md" mb={3}>
Скрытое вычисление (hidden)
</Heading>
<Text fontSize="sm" color="fg.muted" mb={4}>
Поле total вычисляется, но не отображается. Посмотрите DebugValues.
</Text>
<Form
debug
initialValue={{ a: 10, b: 20, total: 0 }}
onSubmit={(data) => setSubmittedData(data as Record<string, unknown>)}
>
<Form.Field.Number name="a" label="A" />
<Form.Field.Number name="b" label="B" />
<Form.Field.Calculated name="total" compute={(v) => (Number(v.a) || 0) + (Number(v.b) || 0)} hidden />
<Form.Button.Submit>Submit</Form.Button.Submit>
</Form>
</Box>
{/* Пример 4: Вложенная группа */}
<Box>
<Heading size="md" mb={3}>
Внутри Form.Group
</Heading>
<Text fontSize="sm" color="fg.muted" mb={4}>
Calculated поле внутри группы — compute получает все values формы.
</Text>
<Form
debug
initialValue={{ order: { price: 500, qty: 4, total: 0 } }}
onSubmit={(data) => setSubmittedData(data as Record<string, unknown>)}
>
<Form.Group name="order">
<Form.Field.Number name="price" label="Цена" />
<Form.Field.Number name="qty" label="Количество" />
<Form.Field.Calculated
name="total"
label="Итого (в группе)"
compute={(v) => {
const order = v.order as Record<string, unknown>
return (Number(order?.price) || 0) * (Number(order?.qty) || 0)
}}
format={(v) => formatRubles(Number(v))}
/>
</Form.Group>
<Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>
</Box>
{/* Результат submit */}
{submittedData && (
<Box p={4} bg="bg.subtle" borderRadius="md">
<Heading size="sm" mb={2}>
Отправленные данные:
</Heading>
<Code whiteSpace="pre-wrap">{JSON.stringify(submittedData, null, 2)}</Code>
</Box>
)}
</VStack>
</DemoPageLayout>
)
}'use client'
import { FieldCalculated, FieldCurrency, FieldNumberInput, FieldPercentage } from '@letar/forms-shadcn'
import { useState } from 'react'
import { DemoForm, DemoPageLayout, SubmittedDataPreview } from '../_components'
interface NumericValues {
price: number
discount: number
stock: number | undefined
finalPrice: number
}
const defaultValues: NumericValues = {
price: 1500,
discount: 15,
stock: 10,
finalPrice: 0,
}
export default function NumericDemoPage() {
const [submitted, setSubmitted] = useState<NumericValues | null>(null)
return (
<DemoPageLayout
title="Числовые поля"
description="Currency, Percentage, NumberInput, Calculated (вычисляемая цена со скидкой)"
>
<DemoForm<NumericValues> defaultValues={defaultValues} onSubmit={setSubmitted}>
<FieldCurrency name="price" label="Цена" />
<FieldPercentage name="discount" label="Скидка" />
<FieldNumberInput name="stock" label="Остаток на складе" min={0} max={999} />
<FieldCalculated
name="finalPrice"
label="Цена со скидкой"
compute={(v) => (v.price as number) * (1 - (v.discount as number) / 100)}
format={(v) => `${Number(v).toLocaleString('ru-RU')} ₽`}
deps={['price', 'discount']}
/>
<button
type="submit"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Отправить
</button>
</DemoForm>
<SubmittedDataPreview data={submitted} />
</DemoPageLayout>
)
}Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.
Overview
Form.Field.Calculated provides declarative computed fields that automatically recalculate when dependent form values change. Instead of manually using Form.Watch + setFieldValue, a single component handles the entire reactive computation.
| Feature | Description |
|---|---|
compute | Function that calculates value from all form values |
format | Optional display formatter (e.g., currency) |
deps | Dependency list for optimized recalculation |
debounce | Throttle heavy computations |
hidden | Compute without rendering (like Hidden) |
Basic Usage
<Form initialValue={{ price: 100, qty: 2, total: 0 }} onSubmit={save}>
<Form.Field.Number name="price" label="Price" />
<Form.Field.Number name="qty" label="Quantity" />
<Form.Field.Calculated
name="total"
label="Total"
compute={(values) => (Number(values.price) || 0) * (Number(values.qty) || 0)}
format={(v) => `${Number(v).toLocaleString()} ₽`}
deps={['price', 'qty']}
/>
<Form.Button.Submit>Save</Form.Button.Submit>
</Form>The computed value is read-only and is included in the submitted form data.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Field path in form state |
compute | (values: Record<string, unknown>) => unknown | — | Compute function (required) |
label | string | — | Field label |
format | (value: unknown) => string | — | Display formatter |
deps | string[] | — | Dependency fields for optimized recalculation |
debounce | number | 0 | Debounce delay in ms |
hidden | boolean | false | Compute without rendering |
helperText | string | — | Helper text below the field |
Cascading Calculations
Multiple calculated fields can depend on each other through a chain:
<Form initialValue={{ price: 2000, qty: 2, discount: 10, subtotal: 0, finalPrice: 0 }}>
<Form.Field.Number name="price" label="Unit Price" />
<Form.Field.Number name="qty" label="Quantity" />
<Form.Field.Calculated
name="subtotal"
label="Subtotal"
compute={(v) => (Number(v.price) || 0) * (Number(v.qty) || 0)}
deps={['price', 'qty']}
/>
<Form.Field.Number name="discount" label="Discount (%)" />
<Form.Field.Calculated
name="finalPrice"
label="Final Price"
compute={(v) => {
const sub = (Number(v.price) || 0) * (Number(v.qty) || 0)
return sub * (1 - (Number(v.discount) || 0) / 100)
}}
format={(v) => `${Number(v).toLocaleString()} ₽`}
deps={['price', 'qty', 'discount']}
/>
</Form>Hidden Mode
Use hidden to compute a value without displaying it — useful for derived IDs, totals for API submission, etc.
<Form.Field.Calculated name="total" compute={(v) => (Number(v.a) || 0) + (Number(v.b) || 0)} hidden />The value will appear in Form.DebugValues and be included on submit.
Performance: deps Optimization
Without deps, the compute function runs on every form value change. For large forms, specify which fields the calculation depends on:
// Only recalculates when price or qty change
<Form.Field.Calculated name="total" compute={(v) => v.price * v.qty} deps={['price', 'qty']} />Inside Form.Group
When inside a group, name is resolved relative to the group. The compute function always receives the full form values:
<Form.Group name="order">
<Form.Field.Number name="price" label="Price" />
<Form.Field.Number name="qty" label="Quantity" />
<Form.Field.Calculated
name="total"
label="Total"
compute={(v) => {
const order = v.order as Record<string, unknown>
return (Number(order?.price) || 0) * (Number(order?.qty) || 0)
}}
/>
</Form.Group>Cycle Protection
The component detects circular dependencies at runtime. If field A's compute triggers field B which references field A, a console error is logged and the cached value is returned.
Comparison: Calculated vs Watch
Form.Field.Calculated | Form.Watch | |
|---|---|---|
| Purpose | Auto-compute a field value | Run arbitrary side effects |
| Renders | Read-only display or hidden | Nothing (renderless) |
| Value in state | Yes, auto-synced | Manual setFieldValue |
| API | Declarative | Imperative |
| Best for | Totals, percentages, derived | Slug generation, cascading selects |
Live Example
Try the interactive example on forms-example.letar.best.