.cursor/docs/practices/react-hooks.md
While working on the Appsmith codebase, we encountered a critical issue in the useSyncParamsToPath React hook that caused infinite re-renders and circular dependency problems. This hook was responsible for synchronizing URL paths and query parameters bidirectionally in the API panel. When a user changed the URL, parameters would get extracted and populated in the form, and when parameters were changed, the URL would get updated.
The initial implementation of the hook had several issues:
Improper property access: The hook was directly accessing nested properties like values.actionConfiguration.path without properly handling the case where these nested paths might not exist.
Missing flexible configuration: The hook couldn't be reused with different property paths since it had hardcoded property paths.
Circular updates: When the hook updated the path, it would trigger a re-render which would then trigger the hook again, causing an infinite loop.
Missing safeguards: The hook didn't have proper tracking of previous values or early exits to prevent unnecessary updates.
We implemented several patterns to fix these issues:
Instead of directly accessing nested properties:
// Before
const path = values.actionConfiguration?.path;
const queryParameters = values.actionConfiguration?.queryParameters;
We used lodash's get function with default values:
// After
import get from 'lodash/get';
const path = get(values, `${configProperty}.path`, "");
const queryParameters = get(values, `${configProperty}.params`, []);
This approach provides several benefits:
configProperty parameterWe implemented a pattern to track previous values and prevent unnecessary updates:
// Refs to track the last values to prevent infinite loops
const lastPathRef = useRef("");
const lastParamsRef = useRef<Property[]>([]);
useEffect(
function syncParamsEffect() {
// Early return if nothing has changed
if (path === lastPathRef.current && isEqual(queryParameters, lastParamsRef.current)) {
return;
}
// Update refs to current values
lastPathRef.current = path;
lastParamsRef.current = [...queryParameters];
// Rest of the effect logic
},
[formValues, dispatch, formName, configProperty],
);
To prevent circular updates, we implemented a pattern where the hook would only process one update direction per effect execution:
// Only one sync direction per effect execution to prevent loops
// Path changed - update params from path if needed
if (pathChanged) {
// Logic to update params from path
// Exit early after updating
return;
}
// Params changed - update path from params if needed
if (paramsChanged) {
// Logic to update path from params
}
For comparing arrays of parameters, we implemented custom comparison logic that compares the actual values rather than just checking references:
// Helper function to check if two arrays of params are functionally equivalent
const areParamsEquivalent = (params1: Property[], params2: Property[]): boolean => {
if (params1.length !== params2.length) return false;
// Create a map of key-value pairs for easier comparison
const paramsMap1 = params1.reduce((map, param) => {
if (param.key) map[param.key] = param.value;
return map;
}, {} as Record<string, any>);
const paramsMap2 = params2.reduce((map, param) => {
if (param.key) map[param.key] = param.value;
return map;
}, {} as Record<string, any>);
return isEqual(paramsMap1, paramsMap2);
};
Always use safe property access:
get with default values?.) but remember it doesn't provide default valuesTrack previous values to prevent infinite loops:
useRef to store previous values between rendersImplement early exits:
isEqual from lodash)Make effects unidirectional in a single execution:
Make hooks flexible and reusable:
configProperty)Test bidirectional hooks thoroughly:
The useSyncParamsToPath hook provides a real-world example of these patterns in action:
// Hook to sync query parameters with URL path in both directions
export const useSyncParamsToPath = (formName: string, configProperty: string) => {
const dispatch = useDispatch();
const formValues = useSelector((state) => getFormData(state, formName));
// Refs to track the last values to prevent infinite loops
const lastPathRef = useRef("");
const lastParamsRef = useRef<Property[]>([]);
useEffect(
function syncParamsEffect() {
if (!formValues || !formValues.values) return;
const values = formValues.values;
const actionId = values.id;
if (!actionId) return;
// Correctly access nested properties using lodash's get
const path = get(values, `${configProperty}.path`, "");
const queryParameters = get(values, `${configProperty}.params`, []);
// Early return if nothing has changed
if (path === lastPathRef.current && isEqual(queryParameters, lastParamsRef.current)) {
return;
}
// Check if params have changed but path hasn't - indicating params tab update
const paramsChanged = !isEqual(queryParameters, lastParamsRef.current);
const pathChanged = path !== lastPathRef.current;
// Update refs to current values
lastPathRef.current = path;
lastParamsRef.current = [...queryParameters];
// Only one sync direction per effect execution to prevent loops
// Path changed - update params from path if needed
if (pathChanged) {
// Logic to update params from path
// Exit early to prevent circular updates
return;
}
// Params changed - update path from params if needed
if (paramsChanged) {
// Logic to update path from params
}
},
[formValues, dispatch, formName, configProperty],
);
};
By implementing these patterns, we fixed the circular dependency and infinite loop issues while making the hook more reusable and robust.