|
| 1 | +# React State Management & useEffect Best Practices |
| 2 | + |
| 3 | +## Critical Rules to Prevent State Leakage and Infinite API Calls |
| 4 | + |
| 5 | +### 1. Minimize useRef Usage |
| 6 | +- **Rule**: Only use `useRef` for values that don't need to trigger re-renders (DOM refs, timeout IDs, stable callbacks) |
| 7 | +- **Anti-pattern**: Using multiple refs to track state changes (e.g., `isSelectingCenterRef`, `isFilterChangingRef`, `isSearchingRef`) |
| 8 | +- **Better approach**: Use state variables or derive from existing state |
| 9 | +- **Example Bad**: |
| 10 | + ```tsx |
| 11 | + const isSelectingCenterRef = useRef(false); |
| 12 | + const isFilterChangingRef = useRef(false); |
| 13 | + const isSearchingRef = useRef(false); |
| 14 | + ``` |
| 15 | +- **Example Good**: Use state or derive from props/state directly |
| 16 | + |
| 17 | +### 2. useEffect Dependency Management |
| 18 | +- **Rule**: Always include ALL dependencies that are used inside useEffect |
| 19 | +- **Rule**: If a function is used in useEffect, wrap it with `useCallback` and include it in dependencies |
| 20 | +- **Anti-pattern**: Using refs to store functions to avoid dependency issues |
| 21 | + ```tsx |
| 22 | + // BAD: Storing function in ref to avoid dependencies |
| 23 | + const searchCentersRef = useRef(null); |
| 24 | + useEffect(() => { |
| 25 | + searchCentersRef.current = searchCenters; |
| 26 | + }, [searchCenters]); |
| 27 | + |
| 28 | + useEffect(() => { |
| 29 | + searchCentersRef.current?.(); // Using ref instead of direct call |
| 30 | + }, [filters]); |
| 31 | + ``` |
| 32 | +- **Better approach**: Use `useCallback` and include in dependencies |
| 33 | + ```tsx |
| 34 | + // GOOD: Stable function with useCallback |
| 35 | + const searchCenters = useCallback(async () => { |
| 36 | + // search logic |
| 37 | + }, [dependencies]); |
| 38 | + |
| 39 | + useEffect(() => { |
| 40 | + searchCenters(); |
| 41 | + }, [filters, searchCenters]); // Include the function |
| 42 | + ``` |
| 43 | + |
| 44 | +### 3. Prevent Circular useEffect Chains |
| 45 | +- **Rule**: Never update state in useEffect that is also in its dependency array |
| 46 | +- **Anti-pattern**: |
| 47 | + ```tsx |
| 48 | + useEffect(() => { |
| 49 | + setSelectedCenter(newCenters); // Updating selectedCenter |
| 50 | + }, [value, centerOptions, selectedCenter]); // selectedCenter is in deps! |
| 51 | + ``` |
| 52 | +- **Better approach**: Remove the state from dependencies or use a different pattern |
| 53 | + ```tsx |
| 54 | + // GOOD: Only depend on external props/state |
| 55 | + useEffect(() => { |
| 56 | + if (value) { |
| 57 | + setSelectedCenter(computeCenters(value, centerOptions)); |
| 58 | + } |
| 59 | + }, [value, centerOptions]); // Don't include selectedCenter |
| 60 | + ``` |
| 61 | + |
| 62 | +### 4. Debouncing and Timeouts |
| 63 | +- **Rule**: Always clean up timeouts in useEffect cleanup |
| 64 | +- **Rule**: Use simple timeout logic, avoid complex ref tracking |
| 65 | +- **Anti-pattern**: |
| 66 | + ```tsx |
| 67 | + const searchTimeoutRef = useRef(null); |
| 68 | + const lastFilterValuesRef = useRef({}); |
| 69 | + |
| 70 | + useEffect(() => { |
| 71 | + // Complex normalization and comparison |
| 72 | + const normalized = /* complex logic */; |
| 73 | + if (lastFilterValuesRef.current !== normalized) { |
| 74 | + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); |
| 75 | + searchTimeoutRef.current = setTimeout(() => { |
| 76 | + // More complex checks with multiple refs |
| 77 | + if (!isSelectingCenterRef.current && !isFilterChangingRef.current) { |
| 78 | + searchCentersRef.current?.(); |
| 79 | + } |
| 80 | + }, delay); |
| 81 | + } |
| 82 | + }, [dependencies]); |
| 83 | + ``` |
| 84 | +- **Better approach**: Simple debouncing with cleanup |
| 85 | + ```tsx |
| 86 | + useEffect(() => { |
| 87 | + const timeoutId = setTimeout(() => { |
| 88 | + searchCenters(); |
| 89 | + }, delay); |
| 90 | + |
| 91 | + return () => clearTimeout(timeoutId); |
| 92 | + }, [dependencies, searchCenters]); |
| 93 | + ``` |
| 94 | + |
| 95 | +### 5. State Synchronization Patterns |
| 96 | +- **Rule**: Avoid bidirectional state synchronization between props and internal state |
| 97 | +- **Rule**: Prefer controlled components pattern (props control state) or uncontrolled (internal state only) |
| 98 | +- **Anti-pattern**: Syncing `value` prop with `selectedCenter` state in both directions |
| 99 | + ```tsx |
| 100 | + // BAD: Bidirectional sync causing loops |
| 101 | + useEffect(() => { |
| 102 | + // Sync value -> selectedCenter |
| 103 | + setSelectedCenter(computeFromValue(value)); |
| 104 | + }, [value]); |
| 105 | + |
| 106 | + const handleChange = (newValue) => { |
| 107 | + setSelectedCenter(newValue); // Update internal state |
| 108 | + onChange(newValue); // Update parent |
| 109 | + }; |
| 110 | + ``` |
| 111 | +- **Better approach**: Single source of truth |
| 112 | + ```tsx |
| 113 | + // GOOD: Use value prop as source of truth, or use internal state only |
| 114 | + const [selectedCenters, setSelectedCenters] = useState(selectedCenterList || []); |
| 115 | + |
| 116 | + // Only sync from props if explicitly provided and different |
| 117 | + useEffect(() => { |
| 118 | + if (selectedCenterList && selectedCenterList !== selectedCenters) { |
| 119 | + setSelectedCenters(selectedCenterList); |
| 120 | + } |
| 121 | + }, [selectedCenterList]); |
| 122 | + ``` |
| 123 | + |
| 124 | +### 6. API Call Optimization |
| 125 | +- **Rule**: Use `useCallback` for API functions to ensure stability |
| 126 | +- **Rule**: Include all dependencies in useCallback, don't use refs to bypass |
| 127 | +- **Rule**: Check conditions before making API calls, not with refs |
| 128 | +- **Anti-pattern**: |
| 129 | + ```tsx |
| 130 | + const isSearchingRef = useRef(false); |
| 131 | + |
| 132 | + const searchCenters = async () => { |
| 133 | + if (isSearchingRef.current) return; // Using ref to prevent calls |
| 134 | + isSearchingRef.current = true; |
| 135 | + // API call |
| 136 | + isSearchingRef.current = false; |
| 137 | + }; |
| 138 | + ``` |
| 139 | +- **Better approach**: Use state or conditions |
| 140 | + ```tsx |
| 141 | + const [isSearching, setIsSearching] = useState(false); |
| 142 | + |
| 143 | + const searchCenters = useCallback(async () => { |
| 144 | + if (isSearching || !hasRequiredFilters) return; |
| 145 | + setIsSearching(true); |
| 146 | + try { |
| 147 | + // API call |
| 148 | + } finally { |
| 149 | + setIsSearching(false); |
| 150 | + } |
| 151 | + }, [isSearching, hasRequiredFilters]); |
| 152 | + ``` |
| 153 | + |
| 154 | +### 7. Filter State Management |
| 155 | +- **Rule**: Keep filter state simple and avoid complex normalization |
| 156 | +- **Rule**: Don't track "last values" in refs to prevent duplicate calls |
| 157 | +- **Anti-pattern**: |
| 158 | + ```tsx |
| 159 | + const lastFilterValuesRef = useRef({}); |
| 160 | + |
| 161 | + useEffect(() => { |
| 162 | + const normalized = /* complex normalization */; |
| 163 | + if (lastFilterValuesRef.current.state !== normalized.state) { |
| 164 | + lastFilterValuesRef.current = normalized; |
| 165 | + search(); |
| 166 | + } |
| 167 | + }, [filters]); |
| 168 | + ``` |
| 169 | +- **Better approach**: React's dependency array handles this |
| 170 | + ```tsx |
| 171 | + useEffect(() => { |
| 172 | + searchCenters(); |
| 173 | + }, [selectedState, selectedDistrict, selectedBlock, searchKeyword, searchCenters]); |
| 174 | + // React automatically prevents duplicate calls if values haven't changed |
| 175 | + ``` |
| 176 | + |
| 177 | +### 8. Handler Functions |
| 178 | +- **Rule**: Keep handlers simple, avoid setting multiple flags/refs |
| 179 | +- **Rule**: Update state directly, let useEffect handle side effects |
| 180 | +- **Anti-pattern**: |
| 181 | + ```tsx |
| 182 | + const handleStateChange = (values) => { |
| 183 | + isFilterChangingRef.current = true; // Setting ref flag |
| 184 | + setSelectedState(values); |
| 185 | + setSelectedDistrict([]); |
| 186 | + setSelectedBlock([]); |
| 187 | + setCenterOptions([]); |
| 188 | + // Clear timeouts, update refs, etc. |
| 189 | + setTimeout(() => { |
| 190 | + isFilterChangingRef.current = false; // Reset flag |
| 191 | + }, 300); |
| 192 | + }; |
| 193 | + ``` |
| 194 | +- **Better approach**: Simple state updates |
| 195 | + ```tsx |
| 196 | + const handleStateChange = (values) => { |
| 197 | + setSelectedState(values); |
| 198 | + setSelectedDistrict([]); |
| 199 | + setSelectedBlock([]); |
| 200 | + // Let useEffect handle clearing options and searching |
| 201 | + }; |
| 202 | + ``` |
| 203 | + |
| 204 | +### 9. Component Lifecycle Management |
| 205 | +- **Rule**: Always use cleanup functions for async operations |
| 206 | +- **Rule**: Use `isMounted` flag for async operations, not refs for preventing calls |
| 207 | +- **Anti-pattern**: |
| 208 | + ```tsx |
| 209 | + useEffect(() => { |
| 210 | + const fetchData = async () => { |
| 211 | + if (isSelectingCenterRef.current) return; // Using ref |
| 212 | + // fetch |
| 213 | + }; |
| 214 | + }, []); |
| 215 | + ``` |
| 216 | +- **Better approach**: |
| 217 | + ```tsx |
| 218 | + useEffect(() => { |
| 219 | + let isMounted = true; |
| 220 | + const fetchData = async () => { |
| 221 | + const data = await apiCall(); |
| 222 | + if (isMounted) { |
| 223 | + setData(data); |
| 224 | + } |
| 225 | + }; |
| 226 | + fetchData(); |
| 227 | + return () => { |
| 228 | + isMounted = false; |
| 229 | + }; |
| 230 | + }, []); |
| 231 | + ``` |
| 232 | + |
| 233 | +### 10. Code Complexity Reduction |
| 234 | +- **Rule**: If you need more than 3-4 refs, reconsider the architecture |
| 235 | +- **Rule**: If useEffect has more than 5 dependencies, consider splitting or refactoring |
| 236 | +- **Rule**: If you need flags to prevent loops, the design needs improvement |
| 237 | +- **Rule**: Prefer multiple simple useEffects over one complex useEffect |
| 238 | + |
| 239 | +## Summary Checklist |
| 240 | + |
| 241 | +Before writing useEffect or state management code, ensure: |
| 242 | + |
| 243 | +- [ ] All dependencies are included in useEffect dependency array |
| 244 | +- [ ] Functions used in useEffect are wrapped with useCallback |
| 245 | +- [ ] No state variable is both updated and in dependency array |
| 246 | +- [ ] Timeouts/intervals are cleaned up in useEffect cleanup |
| 247 | +- [ ] No more than 3-4 refs per component (excluding DOM refs) |
| 248 | +- [ ] No flags/refs used to prevent infinite loops (fix the root cause instead) |
| 249 | +- [ ] Simple debouncing without complex ref tracking |
| 250 | +- [ ] Single source of truth for state (props OR internal state, not both) |
| 251 | +- [ ] API calls are in useCallback with proper dependencies |
| 252 | +- [ ] Async operations check isMounted before setting state |
| 253 | + |
| 254 | +## Example: Good vs Bad Patterns |
| 255 | + |
| 256 | +### Bad Pattern (Causes State Leakage & Infinite Loops) |
| 257 | +```tsx |
| 258 | +// Too many refs |
| 259 | +const isSelectingCenterRef = useRef(false); |
| 260 | +const isFilterChangingRef = useRef(false); |
| 261 | +const isSearchingRef = useRef(false); |
| 262 | +const searchCentersRef = useRef(null); |
| 263 | +const lastFilterValuesRef = useRef({}); |
| 264 | + |
| 265 | +// Function stored in ref |
| 266 | +useEffect(() => { |
| 267 | + searchCentersRef.current = searchCenters; |
| 268 | +}, [searchCenters]); |
| 269 | + |
| 270 | +// Complex useEffect with ref checks |
| 271 | +useEffect(() => { |
| 272 | + if (isSelectingCenterRef.current || isFilterChangingRef.current) return; |
| 273 | + const normalized = /* complex */; |
| 274 | + if (lastFilterValuesRef.current !== normalized) { |
| 275 | + searchTimeoutRef.current = setTimeout(() => { |
| 276 | + if (!isSearchingRef.current) { |
| 277 | + searchCentersRef.current?.(); |
| 278 | + } |
| 279 | + }, delay); |
| 280 | + } |
| 281 | +}, [filters]); |
| 282 | + |
| 283 | +// State in dependency array that's also updated |
| 284 | +useEffect(() => { |
| 285 | + setSelectedCenter(compute(value)); |
| 286 | +}, [value, selectedCenter]); // selectedCenter updated inside! |
| 287 | +``` |
| 288 | + |
| 289 | +### Good Pattern (Clean & Stable) |
| 290 | +```tsx |
| 291 | +// Minimal refs (only for timeout cleanup) |
| 292 | +const timeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 293 | + |
| 294 | +// Stable function with useCallback |
| 295 | +const searchCenters = useCallback(async () => { |
| 296 | + if (!hasRequiredFilters) return; |
| 297 | + setLoading(true); |
| 298 | + try { |
| 299 | + const data = await apiCall(filters); |
| 300 | + setCenters(data); |
| 301 | + } finally { |
| 302 | + setLoading(false); |
| 303 | + } |
| 304 | +}, [filters, hasRequiredFilters]); |
| 305 | + |
| 306 | +// Simple debounced useEffect |
| 307 | +useEffect(() => { |
| 308 | + if (timeoutRef.current) { |
| 309 | + clearTimeout(timeoutRef.current); |
| 310 | + } |
| 311 | + |
| 312 | + timeoutRef.current = setTimeout(() => { |
| 313 | + searchCenters(); |
| 314 | + }, 500); |
| 315 | + |
| 316 | + return () => { |
| 317 | + if (timeoutRef.current) { |
| 318 | + clearTimeout(timeoutRef.current); |
| 319 | + } |
| 320 | + }; |
| 321 | +}, [selectedState, selectedDistrict, searchKeyword, searchCenters]); |
| 322 | + |
| 323 | +// Simple state sync (one direction only) |
| 324 | +useEffect(() => { |
| 325 | + if (value && value !== selectedCenters.map(c => c.value)) { |
| 326 | + setSelectedCenters(computeFromValue(value)); |
| 327 | + } |
| 328 | +}, [value]); // Don't include selectedCenters |
| 329 | +``` |
| 330 | + |
| 331 | +## Key Takeaways |
| 332 | + |
| 333 | +1. **Less is More**: Fewer refs, simpler logic, cleaner code |
| 334 | +2. **Trust React**: React's dependency array handles change detection |
| 335 | +3. **useCallback is Your Friend**: Use it for functions in useEffect |
| 336 | +4. **Single Source of Truth**: Don't sync state bidirectionally |
| 337 | +5. **Fix Root Causes**: If you need flags to prevent loops, redesign |
| 338 | +6. **Keep it Simple**: Complex normalization and comparison usually indicates a design issue |
| 339 | + |
0 commit comments