isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.

useGenerator 의 상태는 useReducer 하나다. idle 에서 start 로 running 이 되고(에러도 함께 비운다), yield 는 status 를 두고 value 만 바꾸며, generator 가 끝나면 done, 본문 어디서든 throw 하면 catch 에서 error 로 errored 다. cancel 은 abort 하고 idle 로 되돌린다 — 안 하면 running 에 멈춘다. unmount 되면 cleanup 이 abort 하고, 기다리던 await wait 가 reject 돼 throw 가 나지만 aborted 라 에러로 치지 않는다. 같은 훅을 쓰는 쪽이 재진입 정책을 고른다: LoadingButton 은 running 동안 disabled 로 다시 못 누르게 하고 JSX 를 yield 하면 그대로 라벨이 된다. 검색은 run(q) 마다 직전 실행을 abort 하고 다시 시작해, a 다음 ab 를 치면 a 의 remote 결과가 끼어들지 않는다.

import { useEffect, useReducer, useRef } from 'react'

type Status = 'idle' | 'running' | 'errored' | 'done'

type State<T> = { status: Status; value: T; error: unknown }

type Action<T> =
  | { type: 'start' }
  | { type: 'yield'; value: T }
  | { type: 'done' }
  | { type: 'error'; error: unknown }
  | { type: 'cancel' }

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'start':
      return { ...state, status: 'running', error: null } // 상태와 에러 초기화가 한 번에 — 따로 set 하다 어긋날 일이 없다
    case 'yield':
      return { ...state, value: action.value }
    case 'done':
      return { ...state, status: 'done' }
    case 'error':
      return { ...state, status: 'errored', error: action.error }
    case 'cancel':
      return { ...state, status: 'idle' } // 없으면 'running' 에 멈춰 있다
  }
}

function useGenerator<T, A extends unknown[]>(
  fn: (signal: AbortSignal, ...args: A) => AsyncGenerator<T>, // signal 은 훅이 첫 인자로 끼워 넣는다
  initial: T,
) {
  const [state, dispatch] = useReducer(reducer<T>, {
    status: 'idle',
    value: initial,
    error: null,
  })
  const ctrlRef = useRef<AbortController | null>(null)

  useEffect(() => () => ctrlRef.current?.abort(), []) // unmount 되면 진행 중인 흐름을 끊는다

  const run = async (...args: A) => {
    // 매 렌더 새로 만들어져 항상 최신 fn 을 읽는다
    ctrlRef.current?.abort() // 재진입 — 직전 실행을 끊고 다시 시작
    const ctrl = new AbortController()
    ctrlRef.current = ctrl
    dispatch({ type: 'start' })
    try {
      for await (const value of fn(ctrl.signal, ...args)) {
        if (ctrl.signal.aborted) return
        dispatch({ type: 'yield', value }) // yield 마다 리렌더
      }
      dispatch({ type: 'done' })
    } catch (error) {
      if (ctrl.signal.aborted) return // abort 로 생긴 throw 는 에러가 아니다
      dispatch({ type: 'error', error })
    }
  }

  const cancel = () => {
    ctrlRef.current?.abort()
    dispatch({ type: 'cancel' })
  }

  return { ...state, run, cancel }
}

검색은 같은 훅에서 재진입이 재시작이 된다 — 새 입력이 들어오면 이전 tier 가 끊긴다.

async function* searchTiers(signal: AbortSignal, q: string): AsyncGenerator<string[]> {
  await wait(100, signal); yield [`${q}:local`]
  await wait(300, signal); yield [`${q}:local`, `${q}:remote`]
}

const { value, run } = useGenerator(searchTiers, [])
<input onChange={(e) => run(e.target.value)} />

0ms 에 a 를 치면 run(a) 가 시작해 100ms 대기 뒤 a:local 을 yield 하고, 300ms 더 기다려 400ms 에 a:remote 를 낼 참이다. 그런데 150ms 에 ab 를 치면 run(ab) 가 직전 실행을 abort 하고, a 가 기다리던 wait 가 reject 돼 그 자리에서 멈춘다 — a:remote 는 오지 않는다. run(ab) 는 250ms 에 ab:local, 550ms 에 ab:local,ab:remote 를 내고 done 이 된다. 화면에는 a:local → ab:local → ab:local,ab:remote 순서로만 보인다. tier 경계(await wait)가 그대로 취소 지점이라 debounce 가 필요 없다.

언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 loading · progressive search(tier 경계 = 취소 지점, debounce 불필요) · optimistic mutation(optimistic | reconciled | rolledBack 전이를 콜백 넷 대신 한 함수로).

확인 — 원본 데모 대비 채운 셋: ERRORED 를 선언만 하고 안 쓰던 것(→ catch), unmount 시 취소(→ AbortSignal + cleanup), 타입(→ 제네릭 훅). 검색창에 a 다음 바로 ab 를 치면 a:remote 가 끼어들지 않고 ab:local → ab:local,ab:remote 로만 바뀌면 된 것이다.


#540