isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.
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)} />
언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 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 로만 바뀌면 된 것이다.
- 출처: @ericclemmons
바깥 클릭 감지를 useEffect 없이 ref callback 하나로 만든다.
React 19 이상에서만 된다.
function useClickOutside(handler) {
const handlerRef = useRef(handler)
handlerRef.current = handler // stale closure 방지
return useCallback((node) => {
const handleClick = (e) => {
if (!node.contains(e.target)) handlerRef.current()
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
}
// 사용
function Modal({ onClose }) {
const ref = useClickOutside(onClose)
return <div ref={ref}>...</div>
}
참고
에이전트 코딩 환경 — 워크트리·포트·자격증명
언제 — 워크트리를 여럿 띄웠는데 서로의 서버를 죽이거나, 의존성 검증이 거짓 통과하거나, 새 워크트리에서 첫 명령이 실패할 때.
| 질문 | 아니오 | 예 |
|---|---|---|
| 24시간 켜진 별도 호스트가 있나 | R8 폐기 | R8까지 |
| 폰·외부 머신에서 세션을 이어받나 | R4를 localhost 포트 격리로 축소 | R4를 프록시 + tailnet URL로 |
| 네이티브 API(Vision·Swift 바인딩)에 의존하나 | R5의 컨테이너 격리 가능 | 컨테이너 불가 — 호스트 직접 실행 + 토큰 축소로만 |
R1. 워크트리를 프로젝트 밖으로
확인 — 훅에 WorktreeCreate 입력을 직접 넣으면 경로 한 줄만 나온다. 그 워크트리에서 의존성을 resolve 하면 워크트리 안 경로가 나온다 — 밖이면 격리가 안 된 것이다.
R2. 워크트리별 결정적 포트
R3. gitignore 된 파일 승계
R4. 프리뷰 서버 규약
R5. 자격증명 축소가 먼저다
확인 — gh auth status 로 스코프를 본다.
R6·R7. 전역 설정과 설계 결정을 세션 밖으로
확인 — 나중에 “왜 이렇게 했지?“를 물었을 때 리포 안에서 답이 나오면 성공. git log 와 트랜스크립트를 뒤져야 하면 실패.
R8. 원격 호스트 (조건부)
에이전트 신뢰 구축 — 검증 경로부터 자동 머지까지
| # | 원칙 | 위반 신호 |
|---|---|---|
| P1 | 에이전트가 스스로 실행해서 확인할 수 없는 작업은 위임하지 않는다 | 스크린샷을 복붙하고 있다 |
| P2 | 구현자와 검증자는 다른 컨텍스트여야 한다 | “수정 완료했습니다”를 구현자가 말한다 |
| P3 | 소프트 규칙(문서·프롬프트)은 신뢰의 근거가 아니다 | CLAUDE.md 에 적었는데 안 지킨다 |
| P4 | 가장 짧은 경로가 정답 경로가 되도록 설계한다 | 에이전트가 매번 우회로를 찾는다 |
| P5 | 사람이 리뷰로 강제하는 불변식은 전부 코드 스멜이다 | 같은 지적을 두 번 했다 |
R00·R01. 거르고 구간 확정
확인 — “지금 에이전트가 만든 PR 을 읽지 않고 머지한다면, 문제가 생겼을 때 며칠 뒤에 알게 되는가?” “모른다”면 구간 0이다.
R02. 검증 스킬 부트스트랩
{
"verdict": "pass | fail | cannot_verify",
"steps_taken": ["실제 실행한 명령"],
"evidence": ["관찰한 원문 출력"],
"unverified": ["실행으로 확인 못 한 항목"]
}
확인 — 같은 버그를 3번 던졌을 때 steps_taken 이 일관되면 통과.
R03. feature map
R04. 실패 모드를 스킬로 승격
R05. eval 하네스
R06. 강제 계층 배치
R07. 리뷰 코멘트를 하드 제약으로
R08. 그린필드 가드레일
확인 — 새 기능 하나를 시켜보고 몇 개 디렉터리를 건드렸는지 센다. 3개를 넘으면 구조가 잘못됐다.