5.7 KiB
React State and Request Patterns
These guidelines define preferred patterns for request handling, state updates, and side-effect management in React applications.
The primary goal is to keep data flow explicit, predictable, maintainable, and performant while avoiding unnecessary rerenders and effect-driven logic.
1. Avoid Effect-Driven Requests
Do not use request functions themselves as dependencies in useEffect.
Avoid patterns like:
useEffect(() => {
fetchData();
}, [fetchData]);
Requests should be triggered explicitly by user actions or lifecycle entry points.
2. Form Requests Should Be Action-Driven
For form-related requests (such as loading Select options):
- Fetch data when the form is opened for the first time.
- If later requests depend on user interactions, trigger them directly inside the interaction handler.
- Do not rely on
useEffectdependency changes to trigger requests.
Recommended:
const handleOnChange = (value) => {
fetchData(value);
};
Avoid:
useEffect(() => {
fetchData(value);
}, [value]);
The action itself should control the request.
3. Update Related States Together
If a single action updates multiple related states:
- Do not synchronize them through
useEffect - Do not derive them indirectly through
useMemo
Instead, update all related states directly inside the action handler.
Recommended:
const handleOnChange = (value) => {
setState1(...);
setState2(...);
buildState(...);
};
Avoid implicit state synchronization chains.
4. Group Strongly Related State
If multiple states are always updated together:
- Do not split them into multiple
useStatecalls. - Prefer a single state object.
Recommended:
const [state, setState] = useState({
state1: ...,
state2: ...,
state3: ...,
});
This reduces unnecessary rerenders and keeps state transitions predictable.
5. Prefer Explicit State Flow
Avoid chaining business logic through multiple useEffect hooks.
Keep:
- request execution
- state updates
- derived calculations
close to the triggering action whenever possible.
Prefer:
const handleAction = () => {
fetchData();
setTableData(...);
setSelectedRow(...);
};
Over:
useEffect(() => {
buildTable();
}, [data]);
useEffect(() => {
updateSelection();
}, [tableData]);
6. Avoid Premature Memoization
Do not use useMemo or useCallback unless there is a confirmed rendering or computation bottleneck.
Overusing memoization:
- increases complexity
- makes state flow harder to understand
- may introduce stale dependency issues
Prefer simple and explicit logic first.
Optimize only when necessary.
7. Keep Request Logic Predictable
A user interaction should clearly show:
- what request is triggered
- which states are updated
- how the UI changes
Avoid indirect update chains caused by dependency-driven effects.
The code should make the request and update flow easy to trace.
8. Prefer Action-Driven Architecture
Prefer:
- action-driven updates
- explicit handlers
- localized state transitions
Over:
- effect-driven synchronization
- cross-hook implicit updates
- reactive chains between states
The triggering action should remain the primary source of truth for UI updates.
Form
Form-specific patterns that build on the rules above. The theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
1. No Fallback for Derived Selection
When "pick A then auto-pick B", match by rule and return undefined if no match — let the corresponding form field stay empty.
Do not silently fall back to list[0] or another default. A fallback hides data issues and tricks the user into thinking they have a valid selection.
const findB = (key, list) =>
key ? list.find((x) => x.key === key) : undefined;
For form fields, prefer clearing with undefined over ''. With Ant Design, undefined restores the placeholder; '' is treated as a real value.
2. Async Race Protection
For fetches triggered by a lifecycle entry (e.g., modal open), tag each invocation with a session ref. Discard stale results if the session has rotated (the modal was closed and re-opened) by the time the response arrives.
const sessionRef = useRef(0);
useEffect(() => {
if (!open) {
sessionRef.current += 1;
return;
}
const session = ++sessionRef.current;
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
if (sessionRef.current !== session) return;
applySelection(as.items[0], findB(as.items[0].key, bs.items));
});
}, [open]);
3. Reference Template
A typical form with two cascading selectors backed by a single shared state:
type Selection = { a?: string; b?: number };
const [selection, setSelection] = useState<Selection>({});
const sessionRef = useRef(0);
const findB = (key, list) =>
key ? list.find((x) => x.key === key) : undefined;
// Single atomic write: state + form together.
const applySelection = (a, b) => {
setSelection({ a: a.name, b: b?.id });
form.current?.setFieldsValue({
field: b?.field,
spec: { ...currentSpec, ...b?.spec }
});
};
// Trigger 1: modal opened
useEffect(() => {
if (!open) {
sessionRef.current++;
setSelection({});
return;
}
const session = ++sessionRef.current;
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
if (sessionRef.current !== session) return;
const first = as.items[0];
applySelection(first, findB(first.key, bs.items));
});
}, [open]);
// Trigger 2: user picks A
const handleAChange = (a) => {
applySelection(a, findB(a.key, listB));
};
// Trigger 3: user picks B
const handleBChange = (b) => {
setSelection((prev) => ({ ...prev, b: b.id }));
form.current?.setFieldsValue({ ...b.fields });
};