React Hook Form Orchestration
Core Rule
Keep cross-field form orchestration in one controller/container layer. Form item components render fields and call explicit handlers; they do not own a distributed form state machine.
Apply This Pattern When
- A field change resets or defaults another field.
- A field change must revalidate another field.
- A watched value triggers an API call, debounced mutation, or quote fetch.
- Validation depends on derived max/min values, token decimals, balances, or quote output.
- A form freezes or loops after typing into a field.
- A component has
useWatch/watchplususeEffectplussetValueortrigger.
Required Structure
Follow a feature-module structure that separates orchestration from fields:
- Put orchestration in a container/controller component, not in
components/formItems/*.form.tsx. - Keep
formItems/*.form.tsxfield-only: render inputs/selects/buttons, read local field state if needed, and call props callbacks. - Put options/constants in
constants/. - Put reusable pure logic in
utils/; no async utilities. - Put non-reusable one-off form logic in the controller, not in module-level hooks.
Avoid
Do not put this pattern in form items:
const value = useWatch({ control, name: 'some_field' });
useEffect(() => {
form.setValue('other_field', nextValue, { shouldValidate: true });
form.trigger('third_field');
}, [value, form]);
This creates a hidden graph:
watch -> render -> effect -> setValue/trigger -> formState update -> render
It becomes especially fragile when the watched result is used as an effect dependency. React Hook Form documents watch/useWatch results as render-phase optimized; use external comparison when they must drive effects.
Preferred Pattern
Make dependent transitions event-driven:
function handleTargetChainChange(chainId: string) {
const asset = getDefaultTargetAsset(chainId);
form.setValue('target_chain', chainId, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('target_asset', asset, {
shouldDirty: true,
shouldValidate: true,
});
if (form.getValues('recipient')) void form.trigger('recipient');
}
Then pass the handler into a field-only form item:
<TargetChainSelect
value={targetChainId}
/>
Quote/API Effects
Keep async effects in the controller and make them key-driven:
const quoteRequest = useMemo(() => {
if (!canQuote) return null;
return {
key: [chain, token, amount, recipient].join('|'),
params,
};
}, [canQuote, chain, token, amount, recipient]);
useEffect(() => {
if (!quoteRequest) return;
const timer = setTimeout(() => fetchQuote(quoteRequest.params), 500);
return () => clearTimeout(timer);
}, [quoteRequest, fetchQuote]);
Do not scatter quote fetching across form items. Do not let quote updates immediately trigger broad validation unless a derived validation key actually changed.
Validation
- Prefer validation based on form values and explicit derived controller state.
- If validation max/min changes, revalidate by a stable primitive key, not by object identity.
- Avoid render-time ref side channels when possible. If a resolver must read a ref, write it in the controller and keep revalidation guarded.
- Avoid
setValue(..., { shouldValidate: true })inside effects caused by form subscriptions.
Review Checklist
Reject or refactor when:
components/formItems/*.form.tsximportsuseWatch,watch,useEffect,setValue, ortriggerfor cross-field behavior.- A form item both reads one field and mutates another.
- A dependency array includes the whole
formobject while the effect callssetValueortrigger. - A debounced API call watches many fields inside a form item.
- Resolver behavior depends on values mutated during child render.
Accept when:
- One controller owns
useForm, watched values, derived values, dependent-field handlers, API effects, and submit disabled state. - Form items are dumb render components.
- Cross-field transitions run from explicit user-event handlers.
- Effects are keyed by stable primitive strings/numbers and guarded against repeated state writes.