Feat/compt 32 async lifecycle hooks#9
Conversation
…useIsFirstRender - usePrevious<T>(value): previous render value via state-derivation; undefined on first render - useToggle(initial?): boolean toggle with stable useCallback reference - useInterval(callback, delay|null): fires at cadence, stops on null, latest callback via ref - useTimeout(callback, delay|null): fires once, cancels on null or unmount, latest callback via ref - useIsFirstRender(): true only on first render (scoped eslint-disable for intentional ref access) - All timer cleanup in useEffect return — StrictMode safe - Zero runtime deps; tsc --noEmit passes, lint passes, 25/25 tests, hooks coverage >= 98% - All five exported from src/hooks/index.ts -> src/index.ts - Changeset added, copilot-instructions.md updated with all three COMPT groups complete
- Moved all 5 hook test files from src/hooks/ to src/hooks/__tests__/ - Updated relative imports from ./hook to ../hook - No logic changes; all 25 tests still pass
|
There was a problem hiding this comment.
Pull request overview
Adds a new “Async & Lifecycle” set of hooks to the HooksKit package, along with tests and public exports.
Changes:
- Introduces five new hooks:
usePrevious,useToggle,useInterval,useTimeout,useIsFirstRender - Exports the new hooks from the hooks barrel (
src/hooks/index.ts) so they’re available viasrc/index.ts - Adds Vitest coverage for the new hooks and a changeset entry for a minor release
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useToggle.ts | New boolean toggle hook with stable callback |
| src/hooks/useTimeout.ts | New timeout hook (delay-controlled) |
| src/hooks/usePrevious.ts | New previous-value hook |
| src/hooks/useIsFirstRender.ts | New first-render detection hook |
| src/hooks/useInterval.ts | New interval hook (delay-controlled) |
| src/hooks/index.ts | Exports the newly added hooks |
| src/hooks/tests/useToggle.test.ts | Adds tests for useToggle |
| src/hooks/tests/useTimeout.test.ts | Adds tests for useTimeout |
| src/hooks/tests/usePrevious.test.ts | Adds tests for usePrevious |
| src/hooks/tests/useIsFirstRender.test.ts | Adds tests for useIsFirstRender |
| src/hooks/tests/useInterval.test.ts | Adds tests for useInterval |
| .github/instructions/copilot-instructions.md | Updates hook group documentation (currently contains a merge conflict marker) |
| .changeset/COMPT-32-async-lifecycle-hooks.md | Declares a minor release and documents the new hooks |
| import { useRef } from 'react'; | ||
|
|
||
| export function useIsFirstRender(): boolean { | ||
| const isFirstRender = useRef(true); | ||
|
|
||
| // Reading and writing ref.current during render is intentional here: | ||
| // isFirstRender tracks mount state only and never drives output directly. | ||
| /* eslint-disable react-hooks/refs */ | ||
| if (isFirstRender.current) { | ||
| isFirstRender.current = false; | ||
| return true; | ||
| } | ||
| /* eslint-enable react-hooks/refs */ | ||
|
|
||
| return false; |
There was a problem hiding this comment.
This hook mutates ref.current during render. In React 18 StrictMode/concurrent rendering, render can be invoked multiple times before commit; this can cause the initial mount to return false on the second render pass. Prefer setting the ref in an effect (e.g., flip a mounted flag in useEffect([]) / useLayoutEffect([])) and only reading it during render.
| import { useRef } from 'react'; | |
| export function useIsFirstRender(): boolean { | |
| const isFirstRender = useRef(true); | |
| // Reading and writing ref.current during render is intentional here: | |
| // isFirstRender tracks mount state only and never drives output directly. | |
| /* eslint-disable react-hooks/refs */ | |
| if (isFirstRender.current) { | |
| isFirstRender.current = false; | |
| return true; | |
| } | |
| /* eslint-enable react-hooks/refs */ | |
| return false; | |
| import { useEffect, useRef } from 'react'; | |
| export function useIsFirstRender(): boolean { | |
| const isFirstRender = useRef(true); | |
| useEffect(() => { | |
| isFirstRender.current = false; | |
| }, []); | |
| return isFirstRender.current; |
| useEffect(() => { | ||
| if (delay === null) return; | ||
|
|
||
| const id = window.setInterval(() => { | ||
| callbackRef.current(); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| window.clearInterval(id); | ||
| }; |
There was a problem hiding this comment.
useInterval uses window.setInterval/window.clearInterval without a typeof window === 'undefined' guard. Other hooks in this package guard window access (e.g., src/hooks/useWindowSize.ts:16-18), and using window here will throw in non-browser runtimes (React Native, some test runners). Prefer globalThis.setInterval/clearInterval (or setInterval/clearInterval) or add a window guard for consistency.
| const id = window.setTimeout(() => { | ||
| callbackRef.current(); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| window.clearTimeout(id); |
There was a problem hiding this comment.
useTimeout uses window.setTimeout/window.clearTimeout without a typeof window === 'undefined' guard. Other hooks in this package guard window access (e.g., src/hooks/useWindowSize.ts:16-18), and using window here will throw in non-browser runtimes. Prefer globalThis.setTimeout/clearTimeout (or setTimeout/clearTimeout) or add a window guard for consistency.
| const id = window.setTimeout(() => { | |
| callbackRef.current(); | |
| }, delay); | |
| return () => { | |
| window.clearTimeout(id); | |
| const id = setTimeout(() => { | |
| callbackRef.current(); | |
| }, delay); | |
| return () => { | |
| clearTimeout(id); |
| @@ -0,0 +1,64 @@ | |||
| import { act, renderHook } from '@testing-library/react'; | |||
| import { describe, expect, it } from 'vitest'; | |||
| import { useToggle } from '../useToggle'; | |||
There was a problem hiding this comment.
Test files for existing hooks are colocated in src/hooks (e.g., src/hooks/useDebounce.test.ts, src/hooks/useWindowSize.test.ts). These new tests live under src/hooks/__tests__, which is inconsistent and makes the test layout harder to scan. Consider colocating these tests alongside the corresponding hook files for consistency.
| import { useToggle } from '../useToggle'; | |
| import { useToggle } from './useToggle'; |
| - **State & Storage** (COMPT-30 ✅) — `useDebounce`, `useLocalStorage`, `useSessionStorage` | ||
| - **DOM & Events** (COMPT-31 ✅) — `useMediaQuery`, `useWindowSize`, `useClickOutside`, `useIntersectionObserver` | ||
| - **Async & Lifecycle** — _(upcoming)_ | ||
| <<<<<<< HEAD |
There was a problem hiding this comment.
The file contains an unresolved merge conflict marker (<<<<<<< HEAD). This will break the markdown/instructions and should be resolved (remove conflict markers and keep the intended content).
| <<<<<<< HEAD |
| import { useState } from 'react'; | ||
|
|
||
| export function usePrevious<T>(value: T): T | undefined { | ||
| const [[prev, curr], setState] = useState<[T | undefined, T]>([undefined, value]); | ||
|
|
||
| if (curr !== value) { | ||
| setState([curr, value]); | ||
| } | ||
|
|
||
| return prev; |
There was a problem hiding this comment.
usePrevious calls setState during render based on curr !== value. This comparison is not safe for all values (e.g., NaN !== NaN), which can trigger an infinite render loop. Consider switching to a ref+effect implementation, or at minimum use Object.is(curr, value) for the comparison to avoid NaN edge cases.
| import { useState } from 'react'; | |
| export function usePrevious<T>(value: T): T | undefined { | |
| const [[prev, curr], setState] = useState<[T | undefined, T]>([undefined, value]); | |
| if (curr !== value) { | |
| setState([curr, value]); | |
| } | |
| return prev; | |
| import { useEffect, useRef } from 'react'; | |
| export function usePrevious<T>(value: T): T | undefined { | |
| const currentRef = useRef<T | undefined>(undefined); | |
| const previousRef = useRef<T | undefined>(undefined); | |
| useEffect(() => { | |
| previousRef.current = currentRef.current; | |
| currentRef.current = value; | |
| }, [value]); | |
| return previousRef.current; |



Summary
Why
Checklist
npm run lintpassesnpm run typecheckpassesnpm testpassesnpm run buildpassesnpx changeset) if this affects consumersNotes