Avoiding Cascading useEffect Hooks

One useEffect triggers a state update that triggers another useEffect that triggers another. By the time you trace the bug, you have lost an afternoon. An event-driven reducer collapses the chain into a single, traceable flow.

June 7, 20269 min read

I have spent whole afternoons debugging this pattern. An effect runs when it should not. The dependency array looks right.

Nothing obvious is wrong. Then you start logging previous and current values, comparing them by hand, and eventually you find the one item that changed when it should not have.

Cascading effects are harder to debug than an infinite loop. An infinite loop at least tells you where it is.

What a Cascade Looks Like

This component automatically searches for flights and then hotels when a user fills in a destination and dates. A classic "smart search" feature.

TSX
function SmartBooking() { const [destination, setDestination] = useState(''); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [isSearchingFlights, setIsSearchingFlights] = useState(false); const [selectedFlight, setSelectedFlight] = useState<Flight | null>(null); const [isSearchingHotels, setIsSearchingHotels] = useState(false); const [selectedHotel, setSelectedHotel] = useState<Hotel | null>(null); const [error, setError] = useState<string | null>(null); // Effect 1: start flight search when all inputs are present useEffect(() => { if (destination && startDate && endDate) { setIsSearchingFlights(true); setSelectedFlight(null); setSelectedHotel(null); setError(null); } }, [destination, startDate, endDate]); // Effect 2: fetch flights when searching starts useEffect(() => { if (!isSearchingFlights) return; searchFlights({ destination, startDate, endDate }) .then(flight => { setSelectedFlight(flight); setIsSearchingFlights(false); }) .catch(err => { setError(err.message); setIsSearchingFlights(false); }); }, [isSearchingFlights]); // Effect 3: start hotel search once a flight is selected useEffect(() => { if (selectedFlight) { setIsSearchingHotels(true); } }, [selectedFlight]); // Effect 4: fetch hotels when hotel search starts useEffect(() => { if (!isSearchingHotels) return; searchHotels({ destination, startDate, endDate, flightId: selectedFlight!.id }) .then(hotel => { setSelectedHotel(hotel); setIsSearchingHotels(false); }) .catch(err => { setError(err.message); setIsSearchingHotels(false); }); }, [isSearchingHotels]); }

Four effects. Each one watches a value that the previous one sets. This is the Rube Goldberg machine: input changes → flag flips → fetch runs → new value set → next flag flips → next fetch runs.

It works right up until it doesn't. Change the destination while hotels are loading. Navigate away mid-search.

Add a fifth step. Every addition requires tracing through all four effects to see which ones will run and in what order.

The cascade chain: inputs change → useEffect 1 sets isSearchingFlights → useEffect 2 fetches flights → useEffect 3 sets isSearchingHotels → useEffect 4 fetches hotels, each step potentially triggering re-renders and re-runs of earlier effects ExpandThe cascade chain: inputs change → useEffect 1 sets isSearchingFlights → useEffect 2 fetches flights → useEffect 3 sets isSearchingHotels → useEffect 4 fetches hotels, each step potentially triggering re-renders and re-runs of earlier effects

Why This Is Harder Than a Loop

An infinite render loop crashes visibly. You know immediately.

Cascading effects fail silently. They produce subtle wrong behavior: a stale hotel result from the previous search, a race condition where the second search resolves before the first, a missing cleanup that leaves a flight selected when the inputs reset. Finding these requires logging every effect, printing previous and current dependency values, and working backward from the symptom to the cause.

The problem is that data changes are not the right unit of intent. An effect that runs "whenever selectedFlight is not null" does not know if the flight was just selected or was left over from a previous search. An event would know -- it would be FLIGHT_SELECTED with an explicit cause.

The Fix: Finite State + Events

Model what the component can be doing as a finite state, then react to status instead of individual data changes.

TypeScript
type BookingStatus = | 'idle' | 'searching-flights' | 'searching-hotels' | 'done' | 'error'; type BookingState = { status: BookingStatus; destination: string; startDate: string; endDate: string; flightId: string | null; hotelId: string | null; error: string | null; }; type BookingAction = | { type: 'INPUT_UPDATED'; destination: string; startDate: string; endDate: string } | { type: 'FLIGHT_FOUND'; flightId: string } | { type: 'HOTEL_FOUND'; hotelId: string } | { type: 'SEARCH_FAILED'; message: string };

The reducer handles the transitions. Crucially, it handles the business logic that was previously scattered across Effects 1 and 3:

TypeScript
function bookingReducer(state: BookingState, action: BookingAction): BookingState { switch (action.type) { case 'INPUT_UPDATED': { const { destination, startDate, endDate } = action; const allPresent = destination && startDate && endDate; return { ...state, destination, startDate, endDate, flightId: null, hotelId: null, error: null, // If all inputs are filled: go straight to searching. // No separate useEffect needed to detect "all three present". status: allPresent ? 'searching-flights' : 'idle', }; } case 'FLIGHT_FOUND': return { ...state, flightId: action.flightId, status: 'searching-hotels' }; case 'HOTEL_FOUND': return { ...state, hotelId: action.hotelId, status: 'done' }; case 'SEARCH_FAILED': return { ...state, error: action.message, status: 'error' }; default: return state; } }

Now a single useEffect handles everything:

TSX
function SmartBooking() { const [state, dispatch] = useReducer(bookingReducer, { status: 'idle', destination: '', startDate: '', endDate: '', flightId: null, hotelId: null, error: null, }); useEffect(() => { let cancelled = false; if (state.status === 'searching-flights') { searchFlights({ destination: state.destination, startDate: state.startDate, endDate: state.endDate }) .then(f => { if (!cancelled) dispatch({ type: 'FLIGHT_FOUND', flightId: f.id }); }) .catch(e => { if (!cancelled) dispatch({ type: 'SEARCH_FAILED', message: e.message }); }); } if (state.status === 'searching-hotels') { searchHotels({ destination: state.destination, startDate: state.startDate, endDate: state.endDate, flightId: state.flightId! }) .then(h => { if (!cancelled) dispatch({ type: 'HOTEL_FOUND', hotelId: h.id }); }) .catch(e => { if (!cancelled) dispatch({ type: 'SEARCH_FAILED', message: e.message }); }); } return () => { cancelled = true; }; }, [state.status]);

One effect, one dependency (state.status), one place to look. When a bug appears, the question is simple: what status are we in? Effects 1 and 3 no longer exist -- their logic moved into the reducer where it is testable and visible.

How to Refactor an Existing Cascade

Working backwards is the most reliable approach:

  1. Run the app and observe. Note every distinct thing that happens: "inputs filled → flight search starts", "flight found → hotel search starts".
  2. Name the events. Each observation becomes an action type: INPUT_UPDATED, FLIGHT_FOUND, HOTEL_FOUND.
  3. Name the statuses. The things happening become statuses: searching-flights, searching-hotels.
  4. Write the reducer. Each action produces the next status. Business logic ("if all inputs present") goes here, not in effects.
  5. Replace the chain with one effect. The effect dispatches events; the reducer decides what happens next.

The chain of effects encodes your application flow implicitly, through timing and dependency arrays. The reducer encodes it explicitly, through state transitions you can read and test.

Extracting the Logic into a Custom Hook

Once the reducer and single effect are working, they belong in a hook (the same extraction pattern covered in useReducer for Complex State):

TSX
function useTripSearch() { const [state, dispatch] = useReducer(bookingReducer, initialState); useEffect(() => { let cancelled = false; if (state.status === 'searching-flights') { searchFlights(state).then(f => { if (!cancelled) dispatch({ type: 'FLIGHT_FOUND', flightId: f.id }); }); } if (state.status === 'searching-hotels') { searchHotels(state).then(h => { if (!cancelled) dispatch({ type: 'HOTEL_FOUND', hotelId: h.id }); }); } return () => { cancelled = true; }; }, [state.status]); return [state, dispatch] as const; }

The component becomes:

TSX
function SmartBooking() { const [state, dispatch] = useTripSearch(); // render based on state.status }

Zero effects in the component. All the complexity is inside the hook, isolated, testable, and invisible to the UI layer.

The Essentials

  1. Cascading effects encode application flow as implicit timing. One effect sets a flag, the next watches the flag. The order is determined by render cycles, not by any visible data structure. Bugs hide in the gaps between effects.
  2. The reducer encodes the same flow explicitly. Business logic ("if all inputs present, start searching") belongs in the reducer as a transition, not in a useEffect that watches for the condition.
  3. Replace N chained effects with one effect on state.status. The effect runs when the status changes. It dispatches events. The reducer decides what the next status is. One dependency, one place to look, one place to fix.

Further Reading and Watching