Асинхронная предзагрузка
Формы часто нужно наполнить данными с сервера: значения редактируемой сущности и справочники для
селектов. Инструменты: начальные значения модели, загрузка через model.patch(...) после
запроса и динамические опции через form.field.updateComponentProps({ options }).
Начальные значения в модели
Начальные значения задаются при создании модели — createModel(initial) или initial сборки. К ним
же возвращает model.reset().
import { createForm, type FormModel, type FormSchemaNode } from '@reformer/core';
import { Input, SelectAsync } from '@reformer/ui-kit';
type ProfileForm = { username: string; language: 'ru' | 'en' };
const profileSchema = (model: FormModel<ProfileForm>): FormSchemaNode => ({
children: [
{ model: model.$.username, component: Input, componentProps: { label: 'Username' } },
{
model: model.$.language,
component: SelectAsync,
componentProps: {
label: 'Язык',
options: [
{ value: 'ru', label: 'Русский' },
{ value: 'en', label: 'English' },
],
},
},
],
});
export const createProfileForm = () =>
createForm<ProfileForm>({ initial: { username: '', language: 'ru' }, schema: profileSchema });
Стабильность сборки
Сборку создавайте один раз хуком useFormBundle(factory) из @reformer/core/react. Иначе
модель и форма пересоздадутся на каждый рендер, а вместе с ними перезапустится и загрузка.
import { useFormBundle } from '@reformer/core/react';
// ❌ пересоздаётся на каждый рендер → загрузка запускается каждый раз
function BadForm() {
const bundle = createProfileForm();
}
// ✅ одна сборка на всё время жизни компонента
function GoodForm() {
const bundle = useFormBundle(createProfileForm);
}
Загрузка значений — AsyncBoundary
Загрузку удобно отдать AsyncBoundary (@reformer/ui-kit или headless-версия из @reformer/cdk):
он ведёт статус (loading / error / ready), отменяет устаревший запрос при смене loadKey и
размонтировании и даёт «Повторить» из коробки. Форме остаётся одно — записать ответ в модель.
import { useFormBundle } from '@reformer/core/react';
import { AsyncBoundary, FormField } from '@reformer/ui-kit';
import type { FormBundle } from '@reformer/core';
interface ProfileData {
profile: Partial<ProfileForm>;
languages: { value: string; label: string }[];
}
async function loadProfile(userId: string, signal: AbortSignal): Promise<ProfileData> {
const [profile, dictionaries] = await Promise.all([
fetchProfile(userId, { signal }),
fetchDictionaries({ signal }),
]);
return { profile, languages: dictionaries.languages };
}
/** Ответ сервера → модель и поля формы. */
function applyProfile({ model, form }: FormBundle<ProfileForm>, data: ProfileData) {
model.patch(data.profile); // только пришедшие ключи
model.captureInitial(); // загруженное — новая точка отсчёта для model.reset()
// справочники — следующим тактом, после реактивных эффектов от patch
queueMicrotask(() => form.language.updateComponentProps({ options: data.languages }));
}
export function ProfileScreen({ userId }: { userId: string | null }) {
const bundle = useFormBundle(createProfileForm);
return (
<AsyncBoundary<ProfileData>
load={(signal) => loadProfile(userId!, signal)}
loadKey={userId}
enabled={userId !== null} // нет id — форма создания, грузить нечего
onSuccess={(data) => applyProfile(bundle, data)}
>
<FormField control={bundle.form.username} />
<FormField control={bundle.form.language} />
</AsyncBoundary>
);
}
model.patch(partial) обновляет только переданные ключи: вложенные группы сливаются, массивы
заменяются целиком. Полная запись — model.set(value): ей нужны все ключи (в dev недостающие
выводятся предупреждением, их значения не трогаются).
queueMicrotask для опцийupdateComponentProps после patch оборачивайте в queueMicrotask: реактивные эффекты от patch
(например, перестроение строк массива) успевают отработать, и опции попадают в уже готовые поля.
captureInitial после загрузкиmodel.reset() возвращает к снимку модели. Чтобы «сброс» возвращал к данным с сервера, вызовите
model.captureInitial() сразу после model.patch(...). Подробнее — в
Отправке и сбросе.
Если форму рисует рендерер по схеме, загрузка записывается правилом узла в поведении формы:
onMount(schema.node('data-boundary'), …) загружает данные, а schema.node(…).patchProps({ status })
передаёт статус узлу AsyncBoundary в controlled-режиме.
Своя загрузка без AsyncBoundary
Если нужен свой хук, обязателен race-guard: пользователь мог сменить id сущности или размонтировать
форму, пока идёт запрос. Его даёт AbortController эффекта.
import { useEffect, useState } from 'react';
export function useLoadProfile(bundle: FormBundle<ProfileForm>, userId: string | null) {
const [state, setState] = useState({ isLoading: userId !== null, error: null as string | null });
useEffect(() => {
if (userId === null) return;
const controller = new AbortController();
setState({ isLoading: true, error: null });
loadProfile(userId, controller.signal)
.then((data) => {
applyProfile(bundle, data);
setState({ isLoading: false, error: null });
})
.catch((error: unknown) => {
if (controller.signal.aborted) return; // сменили userId / размонтировали
setState({ isLoading: false, error: error instanceof Error ? error.message : 'Ошибка' });
});
return () => controller.abort();
}, [bundle, userId]); // bundle стабилен — собран через useFormBundle
return state;
}
Зависимые опции селекта
Опции, зависящие от другого поля (страна → город, бренд → модель), грузите в поведении через
onChange + updateComponentProps. onChange даёт debounce и AbortSignal из коробки:
устаревший запрос отменяется при следующей смене значения.
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
type AddressForm = { country: string; city: string };
async function fetchCities(country: string, opts?: { signal?: AbortSignal }) {
const res = await fetch(`/api/cities?country=${encodeURIComponent(country)}`, {
signal: opts?.signal,
});
return res.json();
}
export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form }) => {
onChange(
model.$.country,
async (country, { signal }) => {
// сбросить зависимое поле при смене источника
model.city = '';
if (!country) {
form.city.updateComponentProps({ options: [] });
return;
}
// состояние загрузки (если компонент его поддерживает)
form.city.updateComponentProps({ loading: true, options: [] });
try {
const options = await fetchCities(country, { signal }); // signal отменяет устаревший запрос
form.city.updateComponentProps({ loading: false, options });
} catch (e) {
if ((e as Error).name === 'AbortError') return; // устаревший запрос — молча выходим
form.city.updateComponentProps({ loading: false, options: [] });
}
},
{ debounce: 300 }
);
});
createForm<AddressForm>({ initial, schema: addressSchema, behavior: addressBehavior });
Жизненный цикл:
onChangeподписан наmodel.$.country.- Debounce 300 мс — не дёргаем сервер на каждое нажатие.
fetchCities(country, { signal })—signalотменяет устаревший запрос при следующей смене.- Результат кладётся в
updateComponentProps({ options }),Selectобновляется через сигналcomponentProps.
При смене поля-источника очищайте зависимое значение (model.city = ''), иначе останется город,
которого нет в новом списке. onChange не срабатывает на инициализации (по умолчанию
immediate: false) — для запуска сразу передайте { immediate: true }.
Дальше
- Модель данных —
patch/set/reset/captureInitial. - Реакции на изменения —
onChange, debounce и AbortSignal. - Отправка и сброс — обратная сторона жизненного цикла.