From 617d89a603c21e0e1f6c719c9d0efb074daa7630 Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Sun, 11 Oct 2026 18:54:39 +0900 Subject: [PATCH 1/9] feat(useDebouncedState): add new hook useDebouncedState --- .../plugin/skills/react-simplikit/SKILL.md | 1 + .../references/useDebouncedState.md | 104 ++++++++ .../useDebouncedState/es/useDebouncedState.md | 99 ++++++++ .../src/hooks/useDebouncedState/index.ts | 1 + .../useDebouncedState/ja/useDebouncedState.md | 99 ++++++++ .../useDebouncedState/ko/useDebouncedState.md | 99 ++++++++ .../useDebouncedState/useDebouncedState.md | 104 ++++++++ .../useDebouncedState.spec.ts | 232 ++++++++++++++++++ .../useDebouncedState/useDebouncedState.ts | 73 ++++++ .../zh-Hans/useDebouncedState.md | 99 ++++++++ packages/react-simplikit/src/index.ts | 1 + 11 files changed, 912 insertions(+) create mode 100644 packages/plugin/skills/react-simplikit/references/useDebouncedState.md create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/index.ts create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts create mode 100644 packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index cdcae987..c4aa3fec 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -82,6 +82,7 @@ Backticks in this table mark catalog entries only. | [`useCounter`](references/useCounter.md) | `useCounter` is a React hook that manages a numeric counter state with increment, decrement, and reset capabilities. | | [`useDebounce`](references/useDebounce.md) | `useDebounce` is a React hook that returns a debounced version of the provided callback function. | | [`useDebouncedCallback`](references/useDebouncedCallback.md) | `useDebouncedCallback` is a React hook that returns a debounced version of the provided callback function. | +| [`useDebouncedState`](references/useDebouncedState.md) | `useDebouncedState` is a React hook that manages state and returns a debounced setter. | | [`useDebouncedValue`](references/useDebouncedValue.md) | `useDebouncedValue` is a React hook that returns a debounced copy of the given value. | | [`useDoubleClick`](references/useDoubleClick.md) | `useDoubleClick` is a React hook that differentiates between single and double click events. | | [`useGeolocation`](references/useGeolocation.md) | `useGeolocation` is a React hook that retrieves and tracks the user's geographical location. | diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md new file mode 100644 index 00000000..c4b450d2 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md @@ -0,0 +1,104 @@ +# useDebouncedState + +`useDebouncedState` is a React hook that manages state and returns a debounced setter. +With the default options, the state updates `debounceMs` milliseconds after the last setter call. +It is useful for applying only the last value from a series of rapid update requests, such as search input. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md new file mode 100644 index 00000000..9f8063a8 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` es un Hook de React que gestiona el estado y devuelve una función de actualización con debounce. Con las opciones predeterminadas, el estado se actualiza `debounceMs` milisegundos después de tu última llamada a la función de actualización. Es útil para aplicar solo el último valor de una serie de solicitudes de actualización rápidas, como las de un campo de búsqueda. + +El valor inicial se devuelve sin cambios en el primer renderizado y en el servidor. No se programa ninguna actualización al montar el componente, por lo que, con `leading: true`, tu primera llamada a la función de actualización después del montaje se aplica de inmediato. Si tanto `leading` como `trailing` son `false`, el estado nunca se actualiza. + +Las actualizaciones pendientes se cancelan cuando el componente se desmonta o cuando cambian `debounceMs`, `leading` o `trailing`. + +## Interfaz + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parámetros + + + + + + + +### Valor de retorno + + + +## Ejemplo + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/index.ts b/packages/react-simplikit/src/hooks/useDebouncedState/index.ts new file mode 100644 index 00000000..dc79e5b9 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/index.ts @@ -0,0 +1 @@ +export { useDebouncedState } from './useDebouncedState.ts'; diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md new file mode 100644 index 00000000..1207687c --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` は、状態を管理し、デバウンスされたセッターを返す React フックです。デフォルトの設定では、最後にセッターを呼び出してから `debounceMs` ミリ秒後に状態が更新されます。検索入力のように更新リクエストが連続して発生する場合に、最後の値だけを反映するのに便利です。 + +初回レンダリング時とサーバー上では、初期値をそのまま返します。マウント時には更新を予約しないため、`leading: true` の場合、マウント後の最初のセッター呼び出しが即座に反映されます。`leading` と `trailing` が両方とも `false` の場合、状態は更新されません。 + +コンポーネントがアンマウントされたとき、または `debounceMs`、`leading`、`trailing` が変更されたときは、保留中の更新がキャンセルされます。 + +## インターフェース + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md new file mode 100644 index 00000000..917fe69a --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState`는 상태를 직접 관리하고, 디바운스가 적용된 setter를 반환하는 리액트 훅이에요. 기본 설정에서는 마지막 setter 호출 뒤 `debounceMs` 밀리초가 지나면 상태가 갱신돼요. 검색 입력처럼 연속으로 발생하는 변경 요청을 마지막 값으로 모아 반영할 때 유용해요. + +첫 렌더와 서버 렌더에서는 초기값을 그대로 반환해요. 마운트 시점에는 갱신을 예약하지 않으므로, `leading: true`이면 마운트 뒤 첫 setter 호출이 즉시 반영돼요. `leading`과 `trailing`이 모두 `false`이면 상태가 갱신되지 않아요. + +컴포넌트가 언마운트되거나 `debounceMs`, `leading`, `trailing`이 변경되면 대기 중인 갱신을 취소해요. + +## 인터페이스 + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md new file mode 100644 index 00000000..c4b450d2 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md @@ -0,0 +1,104 @@ +# useDebouncedState + +`useDebouncedState` is a React hook that manages state and returns a debounced setter. +With the default options, the state updates `debounceMs` milliseconds after the last setter call. +It is useful for applying only the last value from a series of rapid update requests, such as search input. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts new file mode 100644 index 00000000..ddc12ada --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -0,0 +1,232 @@ +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useDebouncedState } from './useDebouncedState.ts'; + +function actions(result: { current: [T, (nextValue: T) => void] }) { + function getValue() { + return result.current[0]; + } + + function setValue(value: T) { + act(() => { + result.current[1](value); + }); + } + + return { getValue, setValue }; +} + +describe('useDebouncedState', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('is safe on server side rendering', async () => { + const result = renderHookSSR.serverOnly(() => useDebouncedState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('returns the initial value on first render', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('keeps the previous state until debounceMs elapses with default options', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('b'); + }); + + it('restarts the delay on each setter call and applies only the last value', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + + setValue('c'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(59); + }); + expect(getValue()).toBe('c'); + }); + + it('applies the last additional value after the delay when both leading and trailing are true', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: true })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('c'); + }); + + it('applies only the first value of each burst immediately when leading is true and trailing is false', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(getValue()).toBe('b'); + + setValue('d'); + expect(getValue()).toBe('d'); + }); + + it('never updates the state when both leading and trailing are false', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: false, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('a'); + }); + + it('never exposes a pending intermediate value when the last setter call returns to the current value', () => { + const valueLog: string[] = []; + const { result } = renderHookSSR(() => { + const state = useDebouncedState('a', 100); + valueLog.push(state[0]); + return state; + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('a'); + + act(() => { + vi.advanceTimersByTime(50); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(getValue()).toBe('a'); + expect(valueLog).not.toContain('b'); + }); + + it('stores a function initial value without calling it', () => { + const fn = vi.fn(() => 'result'); + const { result } = renderHookSSR(() => useDebouncedState(fn, 100)); + + expect(result.current[0]).toBe(fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('stores a function passed to the setter without calling it', () => { + const initialFn = () => 'initial'; + const nextFn = vi.fn(() => 'next'); + const { result } = renderHookSSR(() => useDebouncedState(initialFn, 100)); + + const { getValue, setValue } = actions(result); + + setValue(nextFn); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(nextFn); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('cancels a pending update when debounceMs changes and uses the new delay for subsequent calls', () => { + const { result, rerender } = renderHookSSR(({ debounceMs }) => useDebouncedState('a', debounceMs), { + initialProps: { debounceMs: 100 }, + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ debounceMs: 200 }); + act(() => vi.advanceTimersByTime(200)); + expect(getValue()).toBe('a'); + + setValue('c'); + act(() => vi.advanceTimersByTime(199)); + expect(getValue()).toBe('a'); + act(() => vi.advanceTimersByTime(1)); + expect(getValue()).toBe('c'); + }); + + it('cancels a pending update when debounce options changes and applies subsequent calls immediately', () => { + const { result, rerender } = renderHookSSR(({ leading }) => useDebouncedState('a', 100, { leading }), { + initialProps: { leading: false }, + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ leading: true }); + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('a'); + + setValue('c'); + expect(getValue()).toBe('c'); + }); + + it('cancels the pending timer on unmount', () => { + const { result, unmount } = renderHookSSR(() => useDebouncedState('a', 100)); + const { setValue } = actions(result); + + setValue('b'); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts new file mode 100644 index 00000000..c6ea9570 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -0,0 +1,73 @@ +import { useRef, useState } from 'react'; + +import { useDebounce } from '../useDebounce/useDebounce.ts'; + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * @description + * `useDebouncedState` is a React hook that manages state and returns a debounced setter. + * With the default options, the state updates `debounceMs` milliseconds after the last setter call. + * It is useful for applying only the last value from a series of rapid update requests, such as search input. + * + * The initial value is returned as is on the first render and on the server. No update is scheduled + * on mount, so with `leading: true` the first setter call after mount is applied immediately. + * If both `leading` and `trailing` are `false`, the state never updates. + * + * Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or + * `trailing` changes. + * + * @template T - The type of the state value. + * @param {T} initialValue - The initial state value, used only on the first render. + * @param {number} debounceMs - The number of milliseconds to wait after the last setter call before updating. + * @param {DebounceOptions} [options] - Configuration options for debounce behavior. + * @param {boolean} [options.leading=false] - If `true`, the first setter call after an idle period is applied immediately. + * @param {boolean} [options.trailing=true] - If `true`, the last value is applied after `debounceMs` milliseconds. + * + * @returns {[state: T, setState: (nextValue: T) => void]} A tuple: + * - state `T` - The current state value; + * - setState `(nextValue: T) => void` - A debounced setter that accepts the next state value; + * + * @example + * import { useDebouncedState } from 'react-simplikit'; + * + * function SearchInput() { + * const [query, setQuery] = useDebouncedState('', 300); + * + * return ( + * <> + * + * {query} + * + * ); + * } + */ +export function useDebouncedState( + initialValue: T, + debounceMs: number, + { leading = false, trailing = true }: DebounceOptions = {} +): [T, (nextValue: T) => void] { + const [debouncedValue, setDebouncedValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: T) { + const unchanged = Object.is(lastForwardedRef.current, value); + const settled = Object.is(value, debouncedValue); + if (unchanged && settled) { + return; + } + + lastForwardedRef.current = value; + setDebouncedValue(() => value); + } + + const debounced = useDebounce((next: T) => forwardChangedValue(next), debounceMs, { leading, trailing }); + + return [debouncedValue, debounced]; +} diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md new file mode 100644 index 00000000..b3c46721 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` 是一个管理状态并返回防抖状态设置函数的 React Hook。使用默认选项时,状态会在最后一次调用状态设置函数的 `debounceMs` 毫秒后更新。它适用于搜索输入等场景,可以将一连串快速的更新请求合并为最后一个值进行更新。 + +首次渲染和服务端渲染时,初始值会原样返回。挂载时不会安排更新,因此设置 `leading: true` 后,挂载后的第一次状态设置函数调用会立即生效。如果 `leading` 和 `trailing` 均为 `false`,状态将不会更新。 + +组件卸载或 `debounceMs`、`leading`、`trailing` 发生变化时,待处理的更新会被取消。 + +## 接口 + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index ea9ff934..d20adc8d 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -11,6 +11,7 @@ export { useControlledState } from './hooks/useControlledState/index.ts'; export { useCounter } from './hooks/useCounter/index.ts'; export { useDebounce } from './hooks/useDebounce/index.ts'; export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts'; +export { useDebouncedState } from './hooks/useDebouncedState/index.ts'; export { useDebouncedValue } from './hooks/useDebouncedValue/index.ts'; export { useDoubleClick } from './hooks/useDoubleClick/index.ts'; export { useGeolocation } from './hooks/useGeolocation/index.ts'; From 348bf5513e0818b63a17238ea711600cacfda04c Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 12:51:50 +0900 Subject: [PATCH 2/9] feat(useThrottledState): add new hook useThrottledState --- .../plugin/skills/react-simplikit/SKILL.md | 1 + .../references/useThrottledState.md | 101 ++++++++ .../useThrottledState/es/useThrottledState.md | 100 ++++++++ .../src/hooks/useThrottledState/index.ts | 1 + .../useThrottledState/ja/useThrottledState.md | 101 ++++++++ .../useThrottledState/ko/useThrottledState.md | 100 ++++++++ .../useThrottledState/useThrottledState.md | 101 ++++++++ .../useThrottledState.spec.ts | 236 ++++++++++++++++++ .../useThrottledState/useThrottledState.ts | 70 ++++++ .../zh-Hans/useThrottledState.md | 100 ++++++++ packages/react-simplikit/src/index.ts | 1 + 11 files changed, 912 insertions(+) create mode 100644 packages/plugin/skills/react-simplikit/references/useThrottledState.md create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/index.ts create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts create mode 100644 packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index c4aa3fec..cf8a27e8 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -111,6 +111,7 @@ Backticks in this table mark catalog entries only. | [`useStorageState`](references/useStorageState.md) | `useStorageState` is a React hook that functions like `useState` but persists the state value in browser storage. | | [`useThrottle`](references/useThrottle.md) | `useThrottle` is a React hook that creates a throttled version of a callback function. | | [`useThrottledCallback`](references/useThrottledCallback.md) | `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. | +| [`useThrottledState`](references/useThrottledState.md) | `useThrottledState` is a React hook that manages state and returns a throttled setter. | | [`useThrottledValue`](references/useThrottledValue.md) | `useThrottledValue` is a React hook that returns a throttled copy of the given value. | | [`useTimeout`](references/useTimeout.md) | `useTimeout` is a React hook that executes a callback function after a specified delay. | | [`useToggle`](references/useToggle.md) | `useToggle` is a React hook that simplifies managing a boolean state. | diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledState.md b/packages/plugin/skills/react-simplikit/references/useThrottledState.md new file mode 100644 index 00000000..bd00b090 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useThrottledState.md @@ -0,0 +1,101 @@ +# useThrottledState + +`useThrottledState` is a React hook that manages state and returns a throttled setter. +With the default options, the first setter call is applied immediately, and subsequent calls +are throttled using `throttleMs`, with the last pending value applied on the trailing edge. +It is useful for limiting state updates from frequent events, such as scrolling. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `throttleMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md new file mode 100644 index 00000000..e8405914 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState` es un Hook de React que gestiona el estado y devuelve un setter con limitación de frecuencia. +Con las opciones predeterminadas, la primera llamada al setter se aplica inmediatamente y las llamadas posteriores +se limitan en frecuencia según `throttleMs`, con el último valor pendiente aplicado en el extremo final (trailing). +Es útil para limitar las actualizaciones de estado provocadas por eventos frecuentes, como el desplazamiento. + +El valor inicial se devuelve tal cual en el primer renderizado y en el servidor. No se programa ninguna actualización +al montar el componente, por lo que, con `leading: true`, la primera llamada al setter después del montaje se aplica inmediatamente. +Si tanto `leading` como `trailing` son `false`, el estado nunca se actualiza. + +Las actualizaciones pendientes se cancelan cuando el componente se desmonta o cuando cambian `throttleMs`, `leading` o `trailing`. + +## Interfaz + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parámetros + + + + + + + +### Valor de retorno + + + +## Ejemplo + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useThrottledState/index.ts b/packages/react-simplikit/src/hooks/useThrottledState/index.ts new file mode 100644 index 00000000..5fd4a4ef --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/index.ts @@ -0,0 +1 @@ +export { useThrottledState } from './useThrottledState.ts'; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md new file mode 100644 index 00000000..b6eb9479 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md @@ -0,0 +1,101 @@ +# useThrottledState + +`useThrottledState` は、状態を管理し、スロットリングされた setter を返す React フックです。 +デフォルトのオプションでは、最初の setter 呼び出しをすぐに反映し、それ以降の呼び出しには +`throttleMs` に基づくスロットリングを適用します。保留中の最後の値は trailing のタイミングで反映します。 +スクロールなど、頻繁に発生するイベントによる状態の更新頻度を制限する場合に便利です。 + +初回レンダリング時とサーバー上では、初期値をそのまま返します。マウント時には更新を予約しないため、 +`leading: true` の場合、マウント後の最初の setter 呼び出しがすぐに反映されます。 +`leading` と `trailing` が両方とも `false` の場合、状態は更新されません。 + +コンポーネントがアンマウントされるか、`throttleMs`、`leading`、`trailing` が変更されると、 +保留中の更新はキャンセルされます。 + +## インターフェース + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md new file mode 100644 index 00000000..ac3df7b6 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState`는 상태를 직접 관리하고, 스로틀이 적용된 setter를 반환하는 리액트 훅이에요. +기본 설정에서는 첫 setter 호출을 즉시 반영하고, 이후 호출에는 `throttleMs`를 기준으로 스로틀을 적용하며, +대기 중인 마지막 값은 trailing 시점에 반영해요. +스크롤처럼 자주 발생하는 이벤트의 상태 갱신 빈도를 제한할 때 유용해요. + +첫 렌더와 서버 렌더에서는 초기값을 그대로 반환해요. 마운트 시점에는 갱신을 예약하지 않으므로, +`leading: true`이면 마운트 뒤 첫 setter 호출이 즉시 반영돼요. +`leading`과 `trailing`이 모두 `false`이면 상태가 갱신되지 않아요. + +컴포넌트가 언마운트되거나 `throttleMs`, `leading`, `trailing`이 변경되면 대기 중인 갱신을 취소해요. + +## 인터페이스 + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md new file mode 100644 index 00000000..bd00b090 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md @@ -0,0 +1,101 @@ +# useThrottledState + +`useThrottledState` is a React hook that manages state and returns a throttled setter. +With the default options, the first setter call is applied immediately, and subsequent calls +are throttled using `throttleMs`, with the last pending value applied on the trailing edge. +It is useful for limiting state updates from frequent events, such as scrolling. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `throttleMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts new file mode 100644 index 00000000..4be168ed --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -0,0 +1,236 @@ +import { act, cleanup } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useThrottledState } from './useThrottledState.ts'; + +function actions(result: { current: [T, (nextValue: T) => void] }) { + function getValue() { + return result.current[0]; + } + + function setValue(value: T) { + act(() => { + result.current[1](value); + }); + } + + return { getValue, setValue }; +} + +describe('useThrottledState', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('is safe on server side rendering', () => { + const result = renderHookSSR.serverOnly(() => useThrottledState('a', 100)); + + expect(result.current[0]).toBe('a'); + }); + + it('should return the initial value', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('should skip the leading edge when leading is false', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100, { leading: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('b'); + }); + + it('should throttle continuous state updates', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('c'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('d'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('e'); + expect(getValue()).toBe('e'); + }); + + it('should update on leading and trailing edges by default', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('c'); + }); + + it('should skip the trailing edge when trailing is false', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100, { leading: true, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(getValue()).toBe('b'); + + setValue('d'); + expect(getValue()).toBe('d'); + }); + + it('should not update when both edges are disabled', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100, { leading: false, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('a'); + }); + + it('should discard a pending value when returning to the current state', () => { + const valueLog: string[] = []; + const { result } = renderHookSSR(() => { + const state = useThrottledState('a', 100, { leading: false }); + valueLog.push(state[0]); + return state; + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('a'); + + act(() => { + vi.advanceTimersByTime(50); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(getValue()).toBe('a'); + expect(valueLog).not.toContain('b'); + }); + + it('should store a function initial value without calling it', () => { + const fn = vi.fn(() => 'result'); + const { result } = renderHookSSR(() => useThrottledState(fn, 100)); + + expect(result.current[0]).toBe(fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('should store a function update without calling it', () => { + const initialFn = () => 'initial'; + const nextFn = vi.fn(() => 'next'); + const { result } = renderHookSSR(() => useThrottledState(initialFn, 100)); + + const { getValue, setValue } = actions(result); + + setValue(nextFn); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(nextFn); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reset throttling when throttleMs changes', () => { + const { result, rerender } = renderHookSSR( + ({ throttleMs }) => useThrottledState('a', throttleMs, { leading: false }), + { + initialProps: { throttleMs: 100 }, + } + ); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ throttleMs: 200 }); + act(() => vi.advanceTimersByTime(200)); + expect(getValue()).toBe('a'); + + setValue('c'); + act(() => vi.advanceTimersByTime(199)); + expect(getValue()).toBe('a'); + act(() => vi.advanceTimersByTime(1)); + expect(getValue()).toBe('c'); + }); + + it('should reset throttling when throttle options changes', () => { + const { result, rerender } = renderHookSSR(({ leading }) => useThrottledState('a', 100, { leading }), { + initialProps: { leading: false }, + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ leading: true }); + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('a'); + + setValue('c'); + expect(getValue()).toBe('c'); + }); + + it('should cancel pending updates on unmount', () => { + const { result, unmount } = renderHookSSR(() => useThrottledState('a', 100, { leading: false })); + const { setValue } = actions(result); + + setValue('b'); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts new file mode 100644 index 00000000..8f5890b7 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -0,0 +1,70 @@ +import { useRef, useState } from 'react'; + +import { useThrottle } from '../useThrottle/useThrottle.ts'; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * @description + * `useThrottledState` is a React hook that manages state and returns a throttled setter. + * With the default options, the first setter call is applied immediately, and subsequent calls + * are throttled using `throttleMs`, with the last pending value applied on the trailing edge. + * It is useful for limiting state updates from frequent events, such as scrolling. + * + * The initial value is returned as is on the first render and on the server. No update is scheduled + * on mount, so with `leading: true` the first setter call after mount is applied immediately. + * If both `leading` and `trailing` are `false`, the state never updates. + * + * Pending updates are cancelled when the component unmounts or when `throttleMs`, `leading`, or + * `trailing` changes. + * + * @template T - The type of the state value. + * @param {T} initialValue - The initial state value, used only on the first render. + * @param {number} throttleMs - The length of the throttle window in milliseconds. + * @param {ThrottleOptions} [options] - Configuration options for throttle behavior. + * @param {boolean} [options.leading=true] - If `true`, allows an immediate update at the start of a throttle window. + * @param {boolean} [options.trailing=true] - If `true`, allows a pending update to run after the delay with the latest value. + * + * @returns {[state: T, setState: (nextValue: T) => void]} A tuple: + * - state `T` - The current state value; + * - setState `(nextValue: T) => void` - A throttled setter that accepts the next state value; + * + * @example + * import { useThrottledState } from 'react-simplikit'; + * + * function ScrollPosition() { + * const [scrollTop, setScrollTop] = useThrottledState(0, 200); + * + * return ( + *
setScrollTop(e.currentTarget.scrollTop)}> + *

Scrolled {scrollTop}px

+ *
+ * ); + * } + */ +export function useThrottledState( + initialValue: T, + throttleMs: number, + { leading = true, trailing = true }: ThrottleOptions = {} +): [T, (nextValue: T) => void] { + const [throttledValue, setThrottledValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: T) { + const unchanged = Object.is(lastForwardedRef.current, value); + const settled = Object.is(value, throttledValue); + if (unchanged && settled) { + return; + } + + lastForwardedRef.current = value; + setThrottledValue(() => value); + } + + const throttled = useThrottle((next: T) => forwardChangedValue(next), throttleMs, { leading, trailing }); + + return [throttledValue, throttled]; +} diff --git a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md new file mode 100644 index 00000000..2e9b3be5 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState` 是一个管理状态并返回节流 setter 的 React Hook。 +使用默认选项时,首次调用 setter 会立即生效,后续调用会根据 `throttleMs` 进行节流, +并在 trailing 时机应用最后一个待更新的值。 +它适用于限制滚动等高频事件引起的状态更新频率。 + +首次渲染时和服务端渲染时都会原样返回初始值。挂载时不会安排更新, +因此,当 `leading: true` 时,挂载后第一次调用 setter 会立即生效。 +如果 `leading` 和 `trailing` 都为 `false`,状态不会更新。 + +组件卸载或 `throttleMs`、`leading`、`trailing` 发生变化时,会取消待执行的更新。 + +## 接口 + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: (nextValue: T) => void]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index d20adc8d..e1c00d3b 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -45,6 +45,7 @@ export { useSet } from './hooks/useSet/index.ts'; export { useStorageState } from './hooks/useStorageState/index.ts'; export { useThrottle } from './hooks/useThrottle/index.ts'; export { useThrottledCallback } from './hooks/useThrottledCallback/index.ts'; +export { useThrottledState } from './hooks/useThrottledState/index.ts'; export { useThrottledValue } from './hooks/useThrottledValue/index.ts'; export { useTimeout } from './hooks/useTimeout/index.ts'; export { useToggle } from './hooks/useToggle/index.ts'; From 75214824025c880a9df02f1ffc89934ac03b35f9 Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 12:58:20 +0900 Subject: [PATCH 3/9] test: make test names shorter and more consistent --- .../useDebouncedState.spec.ts | 24 +++++++++---------- .../useThrottledState.spec.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts index ddc12ada..c6e9c3b8 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -30,13 +30,13 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('a'); }); - it('returns the initial value on first render', () => { + it('should return the initial value', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100)); const { getValue } = actions(result); expect(getValue()).toBe('a'); }); - it('keeps the previous state until debounceMs elapses with default options', () => { + it('should update only on the trailing edge by default debounce options', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100)); const { getValue, setValue } = actions(result); @@ -54,7 +54,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('b'); }); - it('restarts the delay on each setter call and applies only the last value', () => { + it('should debounce continuous state updates', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100)); const { getValue, setValue } = actions(result); @@ -78,7 +78,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('c'); }); - it('applies the last additional value after the delay when both leading and trailing are true', () => { + it('should update on leading and trailing edges when both are enabled', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: true })); const { getValue, setValue } = actions(result); @@ -101,7 +101,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('c'); }); - it('applies only the first value of each burst immediately when leading is true and trailing is false', () => { + it('should update only on the leading edge when trailing is false', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: false })); const { getValue, setValue } = actions(result); @@ -123,7 +123,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('d'); }); - it('never updates the state when both leading and trailing are false', () => { + it('should not update when both edges are disabled', () => { const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: false, trailing: false })); const { getValue, setValue } = actions(result); @@ -136,7 +136,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('a'); }); - it('never exposes a pending intermediate value when the last setter call returns to the current value', () => { + it('should discard a pending value when returning to the current state', () => { const valueLog: string[] = []; const { result } = renderHookSSR(() => { const state = useDebouncedState('a', 100); @@ -162,7 +162,7 @@ describe('useDebouncedState', () => { expect(valueLog).not.toContain('b'); }); - it('stores a function initial value without calling it', () => { + it('should store a function initial value without calling it', () => { const fn = vi.fn(() => 'result'); const { result } = renderHookSSR(() => useDebouncedState(fn, 100)); @@ -170,7 +170,7 @@ describe('useDebouncedState', () => { expect(fn).not.toHaveBeenCalled(); }); - it('stores a function passed to the setter without calling it', () => { + it('should store a function update without calling it', () => { const initialFn = () => 'initial'; const nextFn = vi.fn(() => 'next'); const { result } = renderHookSSR(() => useDebouncedState(initialFn, 100)); @@ -187,7 +187,7 @@ describe('useDebouncedState', () => { expect(nextFn).not.toHaveBeenCalled(); }); - it('cancels a pending update when debounceMs changes and uses the new delay for subsequent calls', () => { + it('should reset debouncing when debounceMs changes', () => { const { result, rerender } = renderHookSSR(({ debounceMs }) => useDebouncedState('a', debounceMs), { initialProps: { debounceMs: 100 }, }); @@ -205,7 +205,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('c'); }); - it('cancels a pending update when debounce options changes and applies subsequent calls immediately', () => { + it('should reset debouncing when debounce options changes', () => { const { result, rerender } = renderHookSSR(({ leading }) => useDebouncedState('a', 100, { leading }), { initialProps: { leading: false }, }); @@ -220,7 +220,7 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('c'); }); - it('cancels the pending timer on unmount', () => { + it('should cancel pending updates on unmount', () => { const { result, unmount } = renderHookSSR(() => useDebouncedState('a', 100)); const { setValue } = actions(result); diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts index 4be168ed..75618182 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -79,7 +79,7 @@ describe('useThrottledState', () => { expect(getValue()).toBe('e'); }); - it('should update on leading and trailing edges by default', () => { + it('should update on leading and trailing edges by default throttle options', () => { const { result } = renderHookSSR(() => useThrottledState('a', 100)); const { getValue, setValue } = actions(result); From 698f083038384c3eae8635dc1540a319f75d3e32 Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 13:52:10 +0900 Subject: [PATCH 4/9] chore: add changeset --- .changeset/hip-pugs-joke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hip-pugs-joke.md diff --git a/.changeset/hip-pugs-joke.md b/.changeset/hip-pugs-joke.md new file mode 100644 index 00000000..1f35f625 --- /dev/null +++ b/.changeset/hip-pugs-joke.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': minor +--- + +Add `useDebouncedState` and `useThrottledState`, which manage state and return debounced and throttled setters, respectively. Both hooks support `leading` and `trailing` options and cancel pending updates on unmount or when timing options change. From 951ade2f17ade39f95a0da40f731c44292f3c1fb Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 20:14:00 +0900 Subject: [PATCH 5/9] feat: add support for updater functions --- .../useDebouncedState.spec.ts | 40 +++++++++++++++++-- .../useDebouncedState/useDebouncedState.ts | 16 ++++++-- .../useThrottledState.spec.ts | 35 ++++++++++++++-- .../useThrottledState/useThrottledState.ts | 16 ++++++-- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts index c6e9c3b8..9a7826d0 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -1,3 +1,4 @@ +import { Dispatch, SetStateAction } from 'react'; import { act } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -5,12 +6,12 @@ import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; import { useDebouncedState } from './useDebouncedState.ts'; -function actions(result: { current: [T, (nextValue: T) => void] }) { +function actions(result: { current: [T, Dispatch>] }) { function getValue() { return result.current[0]; } - function setValue(value: T) { + function setValue(value: SetStateAction) { act(() => { result.current[1](value); }); @@ -162,6 +163,39 @@ describe('useDebouncedState', () => { expect(valueLog).not.toContain('b'); }); + it('should support updater functions followed by direct value updates', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('ab'); + + setValue('c'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('c'); + }); + + it('should apply only the last updater function during continuous updates', () => { + const { result } = renderHookSSR(() => useDebouncedState('z', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => { + vi.advanceTimersByTime(99); + }); + + setValue(prev => prev + 'c'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('zc'); + }); + it('should store a function initial value without calling it', () => { const fn = vi.fn(() => 'result'); const { result } = renderHookSSR(() => useDebouncedState(fn, 100)); @@ -177,7 +211,7 @@ describe('useDebouncedState', () => { const { getValue, setValue } = actions(result); - setValue(nextFn); + setValue(() => nextFn); act(() => { vi.advanceTimersByTime(100); diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index c6ea9570..891becdb 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useRef, useState } from 'react'; import { useDebounce } from '../useDebounce/useDebounce.ts'; @@ -52,11 +52,16 @@ export function useDebouncedState( initialValue: T, debounceMs: number, { leading = false, trailing = true }: DebounceOptions = {} -): [T, (nextValue: T) => void] { +): [T, Dispatch>] { const [debouncedValue, setDebouncedValue] = useState(() => initialValue); const lastForwardedRef = useRef(initialValue); - function forwardChangedValue(value: T) { + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + setDebouncedValue(value); + return; + } + const unchanged = Object.is(lastForwardedRef.current, value); const settled = Object.is(value, debouncedValue); if (unchanged && settled) { @@ -67,7 +72,10 @@ export function useDebouncedState( setDebouncedValue(() => value); } - const debounced = useDebounce((next: T) => forwardChangedValue(next), debounceMs, { leading, trailing }); + const debounced = useDebounce((next: SetStateAction) => forwardChangedValue(next), debounceMs, { + leading, + trailing, + }); return [debouncedValue, debounced]; } diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts index 75618182..5a51ca1e 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -1,3 +1,4 @@ +import { Dispatch, SetStateAction } from 'react'; import { act, cleanup } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -5,12 +6,12 @@ import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; import { useThrottledState } from './useThrottledState.ts'; -function actions(result: { current: [T, (nextValue: T) => void] }) { +function actions(result: { current: [T, Dispatch>] }) { function getValue() { return result.current[0]; } - function setValue(value: T) { + function setValue(value: SetStateAction) { act(() => { result.current[1](value); }); @@ -163,6 +164,34 @@ describe('useThrottledState', () => { expect(valueLog).not.toContain('b'); }); + it('should support updater functions followed by direct value updates', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + expect(getValue()).toBe('ab'); + + act(() => vi.advanceTimersByTime(50)); + setValue('c'); + expect(getValue()).toBe('ab'); + + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('c'); + }); + + it('should apply only the last updater function during continuous updates when leading is false', () => { + const { result } = renderHookSSR(() => useThrottledState('z', 100, { leading: false })); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => vi.advanceTimersByTime(99)); + expect(getValue()).toBe('z'); + + setValue(prev => prev + 'c'); + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('zc'); + }); + it('should store a function initial value without calling it', () => { const fn = vi.fn(() => 'result'); const { result } = renderHookSSR(() => useThrottledState(fn, 100)); @@ -178,7 +207,7 @@ describe('useThrottledState', () => { const { getValue, setValue } = actions(result); - setValue(nextFn); + setValue(() => nextFn); act(() => { vi.advanceTimersByTime(100); diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 8f5890b7..3692d96c 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useRef, useState } from 'react'; import { useThrottle } from '../useThrottle/useThrottle.ts'; @@ -49,11 +49,16 @@ export function useThrottledState( initialValue: T, throttleMs: number, { leading = true, trailing = true }: ThrottleOptions = {} -): [T, (nextValue: T) => void] { +): [T, Dispatch>] { const [throttledValue, setThrottledValue] = useState(() => initialValue); const lastForwardedRef = useRef(initialValue); - function forwardChangedValue(value: T) { + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + setThrottledValue(value); + return; + } + const unchanged = Object.is(lastForwardedRef.current, value); const settled = Object.is(value, throttledValue); if (unchanged && settled) { @@ -64,7 +69,10 @@ export function useThrottledState( setThrottledValue(() => value); } - const throttled = useThrottle((next: T) => forwardChangedValue(next), throttleMs, { leading, trailing }); + const throttled = useThrottle((next: SetStateAction) => forwardChangedValue(next), throttleMs, { + leading, + trailing, + }); return [throttledValue, throttled]; } From 3fe3d37b6c6d1935b7f3583ba1c6014c1dab17ed Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 20:17:34 +0900 Subject: [PATCH 6/9] refactor: useDebounce and useThrottle import from index --- .../src/hooks/useDebouncedState/useDebouncedState.ts | 2 +- .../src/hooks/useThrottledState/useThrottledState.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index 891becdb..bbb57501 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react'; -import { useDebounce } from '../useDebounce/useDebounce.ts'; +import { useDebounce } from '../useDebounce/index.ts'; type DebounceOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 3692d96c..2384c8a6 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react'; -import { useThrottle } from '../useThrottle/useThrottle.ts'; +import { useThrottle } from '../useThrottle/index.ts'; type ThrottleOptions = { leading?: boolean; From 742332a3d8c05c494c4f161931bbed675ba03404 Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Mon, 12 Oct 2026 20:53:34 +0900 Subject: [PATCH 7/9] docs: apply new return type JSDoc and docs --- .../react-simplikit/references/useDebouncedState.md | 9 +++++---- .../react-simplikit/references/useThrottledState.md | 9 +++++---- .../src/hooks/useDebouncedState/es/useDebouncedState.md | 8 ++++---- .../src/hooks/useDebouncedState/ja/useDebouncedState.md | 8 ++++---- .../src/hooks/useDebouncedState/ko/useDebouncedState.md | 8 ++++---- .../src/hooks/useDebouncedState/useDebouncedState.md | 9 +++++---- .../src/hooks/useDebouncedState/useDebouncedState.ts | 4 ++-- .../hooks/useDebouncedState/zh-Hans/useDebouncedState.md | 8 ++++---- .../src/hooks/useThrottledState/es/useThrottledState.md | 8 ++++---- .../src/hooks/useThrottledState/ja/useThrottledState.md | 8 ++++---- .../src/hooks/useThrottledState/ko/useThrottledState.md | 8 ++++---- .../src/hooks/useThrottledState/useThrottledState.md | 9 +++++---- .../src/hooks/useThrottledState/useThrottledState.ts | 4 ++-- .../hooks/useThrottledState/zh-Hans/useThrottledState.md | 8 ++++---- 14 files changed, 56 insertions(+), 52 deletions(-) diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md index c4b450d2..89ae61bd 100644 --- a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md @@ -18,7 +18,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parameters @@ -65,7 +65,7 @@ function useDebouncedState( diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledState.md b/packages/plugin/skills/react-simplikit/references/useThrottledState.md index bd00b090..b788a340 100644 --- a/packages/plugin/skills/react-simplikit/references/useThrottledState.md +++ b/packages/plugin/skills/react-simplikit/references/useThrottledState.md @@ -19,7 +19,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parameters @@ -66,7 +66,7 @@ function useThrottledState( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md index 9f8063a8..f4e160ce 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md @@ -13,7 +13,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parámetros @@ -60,7 +60,7 @@ function useDebouncedState( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md index 1207687c..7258f768 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md @@ -13,7 +13,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### パラメータ @@ -60,7 +60,7 @@ function useDebouncedState( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md index 917fe69a..307faa48 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md @@ -13,7 +13,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### 파라미터 @@ -60,7 +60,7 @@ function useDebouncedState( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md index c4b450d2..89ae61bd 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md @@ -18,7 +18,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parameters @@ -65,7 +65,7 @@ function useDebouncedState( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index bbb57501..f32a6277 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -27,9 +27,9 @@ type DebounceOptions = { * @param {boolean} [options.leading=false] - If `true`, the first setter call after an idle period is applied immediately. * @param {boolean} [options.trailing=true] - If `true`, the last value is applied after `debounceMs` milliseconds. * - * @returns {[state: T, setState: (nextValue: T) => void]} A tuple: + * @returns {[state: T, setState: Dispatch>]} A tuple: * - state `T` - The current state value; - * - setState `(nextValue: T) => void` - A debounced setter that accepts the next state value; + * - setState `Dispatch>` - A debounced setter that accepts the next state value or an updater function that receives the previous state; * * @example * import { useDebouncedState } from 'react-simplikit'; diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md index b3c46721..2c36ae72 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md @@ -13,7 +13,7 @@ function useDebouncedState( initialValue: T, debounceMs: number, options?: DebounceOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### 参数 @@ -60,7 +60,7 @@ function useDebouncedState( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md index e8405914..1d2cae83 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md @@ -18,7 +18,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parámetros @@ -65,7 +65,7 @@ function useThrottledState( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md index b6eb9479..e524b280 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md @@ -19,7 +19,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### パラメータ @@ -66,7 +66,7 @@ function useThrottledState( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md index ac3df7b6..4f10d0a0 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md @@ -18,7 +18,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### 파라미터 @@ -65,7 +65,7 @@ function useThrottledState( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md index bd00b090..b788a340 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md @@ -19,7 +19,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### Parameters @@ -66,7 +66,7 @@ function useThrottledState( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 2384c8a6..90dfde73 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -28,9 +28,9 @@ type ThrottleOptions = { * @param {boolean} [options.leading=true] - If `true`, allows an immediate update at the start of a throttle window. * @param {boolean} [options.trailing=true] - If `true`, allows a pending update to run after the delay with the latest value. * - * @returns {[state: T, setState: (nextValue: T) => void]} A tuple: + * @returns {[state: T, setState: Dispatch>]} A tuple: * - state `T` - The current state value; - * - setState `(nextValue: T) => void` - A throttled setter that accepts the next state value; + * - setState `Dispatch>` - A throttled setter that accepts the next state value or an updater function that receives the previous state; * * @example * import { useThrottledState } from 'react-simplikit'; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md index 2e9b3be5..e908332b 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md @@ -18,7 +18,7 @@ function useThrottledState( initialValue: T, throttleMs: number, options?: ThrottleOptions -): [state: T, setState: (nextValue: T) => void]; +): [state: T, setState: Dispatch>]; ``` ### 参数 @@ -65,7 +65,7 @@ function useThrottledState( From b7cac06050f22881fc0384eb17343890ff9d5a06 Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Tue, 13 Oct 2026 21:05:25 +0900 Subject: [PATCH 8/9] docs: update wording setState --- .../skills/react-simplikit/references/useDebouncedState.md | 2 +- .../skills/react-simplikit/references/useThrottledState.md | 2 +- .../src/hooks/useDebouncedState/es/useDebouncedState.md | 2 +- .../src/hooks/useDebouncedState/ja/useDebouncedState.md | 2 +- .../src/hooks/useDebouncedState/ko/useDebouncedState.md | 2 +- .../src/hooks/useDebouncedState/useDebouncedState.md | 2 +- .../src/hooks/useDebouncedState/useDebouncedState.ts | 2 +- .../src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md | 2 +- .../src/hooks/useThrottledState/es/useThrottledState.md | 2 +- .../src/hooks/useThrottledState/ja/useThrottledState.md | 2 +- .../src/hooks/useThrottledState/ko/useThrottledState.md | 2 +- .../src/hooks/useThrottledState/useThrottledState.md | 2 +- .../src/hooks/useThrottledState/useThrottledState.ts | 2 +- .../src/hooks/useThrottledState/zh-Hans/useThrottledState.md | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md index 89ae61bd..0b8f0500 100644 --- a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md @@ -79,7 +79,7 @@ function useDebouncedState( type: 'Dispatch>', required: false, description: - 'A debounced setter that accepts the next state value or an updater function that receives the previous state.', + 'A debounced setter that accepts the next state value or an updater function.', }, ]" /> diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledState.md b/packages/plugin/skills/react-simplikit/references/useThrottledState.md index b788a340..4efffeb1 100644 --- a/packages/plugin/skills/react-simplikit/references/useThrottledState.md +++ b/packages/plugin/skills/react-simplikit/references/useThrottledState.md @@ -80,7 +80,7 @@ function useThrottledState( type: 'Dispatch>', required: false, description: - 'A throttled setter that accepts the next state value or an updater function that receives the previous state.', + 'A throttled setter that accepts the next state value or an updater function.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md index f4e160ce..3a6cb7ff 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md @@ -73,7 +73,7 @@ function useDebouncedState( name: 'setState', type: 'Dispatch>', required: false, - description: 'Una función de actualización con debounce que acepta el siguiente valor del estado o una función que recibe el estado anterior.', + description: 'Una función de actualización con debounce que acepta el siguiente valor del estado o una función de actualización.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md index 7258f768..a525e01f 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md @@ -73,7 +73,7 @@ function useDebouncedState( name: 'setState', type: 'Dispatch>', required: false, - description: '次の状態の値、または前の状態を受け取る更新関数を受け付ける、デバウンスされたセッター。', + description: '次の状態の値、または更新関数を受け付ける、デバウンスされたセッター。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md index 307faa48..d4573f57 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md @@ -73,7 +73,7 @@ function useDebouncedState( name: 'setState', type: 'Dispatch>', required: false, - description: '다음 상태 값이나 이전 상태를 받는 updater 함수를 받는 디바운스된 setter예요.', + description: '다음 상태 값이나 업데이트 함수를 받는 디바운스된 setter예요.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md index 89ae61bd..0b8f0500 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md @@ -79,7 +79,7 @@ function useDebouncedState( type: 'Dispatch>', required: false, description: - 'A debounced setter that accepts the next state value or an updater function that receives the previous state.', + 'A debounced setter that accepts the next state value or an updater function.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index f32a6277..cf697d93 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -29,7 +29,7 @@ type DebounceOptions = { * * @returns {[state: T, setState: Dispatch>]} A tuple: * - state `T` - The current state value; - * - setState `Dispatch>` - A debounced setter that accepts the next state value or an updater function that receives the previous state; + * - setState `Dispatch>` - A debounced setter that accepts the next state value or an updater function; * * @example * import { useDebouncedState } from 'react-simplikit'; diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md index 2c36ae72..d8fb727d 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md +++ b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md @@ -73,7 +73,7 @@ function useDebouncedState( name: 'setState', type: 'Dispatch>', required: false, - description: '接收下一个状态值或以前一个状态为参数的更新函数的防抖状态设置函数。', + description: '接收下一个状态值或更新函数的防抖状态设置函数。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md index 1d2cae83..72194648 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md @@ -78,7 +78,7 @@ function useThrottledState( name: 'setState', type: 'Dispatch>', required: false, - description: 'Un setter con limitación de frecuencia que acepta el siguiente valor del estado o una función que recibe el estado anterior.', + description: 'Un setter con limitación de frecuencia que acepta el siguiente valor del estado o una función de actualización.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md index e524b280..44c115d5 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md @@ -79,7 +79,7 @@ function useThrottledState( name: 'setState', type: 'Dispatch>', required: false, - description: '次の状態の値、または前の状態を受け取る更新関数を受け付ける、スロットリングされた setter。', + description: '次の状態の値、または更新関数を受け付ける、スロットリングされた setter。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md index 4f10d0a0..ff7a23ff 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md @@ -78,7 +78,7 @@ function useThrottledState( name: 'setState', type: 'Dispatch>', required: false, - description: '다음 상태 값이나 이전 상태를 받는 updater 함수를 받는 스로틀된 setter예요.', + description: '다음 상태 값이나 업데이트 함수를 받는 스로틀된 setter예요.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md index b788a340..4efffeb1 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md @@ -80,7 +80,7 @@ function useThrottledState( type: 'Dispatch>', required: false, description: - 'A throttled setter that accepts the next state value or an updater function that receives the previous state.', + 'A throttled setter that accepts the next state value or an updater function.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 90dfde73..4f6bc96d 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -30,7 +30,7 @@ type ThrottleOptions = { * * @returns {[state: T, setState: Dispatch>]} A tuple: * - state `T` - The current state value; - * - setState `Dispatch>` - A throttled setter that accepts the next state value or an updater function that receives the previous state; + * - setState `Dispatch>` - A throttled setter that accepts the next state value or an updater function; * * @example * import { useThrottledState } from 'react-simplikit'; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md index e908332b..0a324143 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md +++ b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md @@ -78,7 +78,7 @@ function useThrottledState( name: 'setState', type: 'Dispatch>', required: false, - description: '接收下一个状态值或以前一个状态为参数的更新函数的节流 setter。', + description: '接收下一个状态值或更新函数的节流 setter。', }, ]" /> From 9acd90e4b8c12ca692b8ac21fd89daa3e2ab4ccd Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Wed, 14 Oct 2026 13:51:42 +0900 Subject: [PATCH 9/9] fix(useDebouncedState, useThrottledState): apply direct values after functional updates --- .../useDebouncedState/useDebouncedState.spec.ts | 13 +++++++++++++ .../hooks/useDebouncedState/useDebouncedState.ts | 5 ++++- .../useThrottledState/useThrottledState.spec.ts | 13 +++++++++++++ .../hooks/useThrottledState/useThrottledState.ts | 5 ++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts index 9a7826d0..54995abe 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -196,6 +196,19 @@ describe('useDebouncedState', () => { expect(getValue()).toBe('zc'); }); + it('should apply a direct value after an updater without a render in between', () => { + const { result } = renderHookSSR(() => useDebouncedState(0, 100, { leading: true })); + const { getValue, setValue } = actions(result); + + act(() => { + setValue(prev => prev + 1); + setValue(0); + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(0); + }); + it('should store a function initial value without calling it', () => { const fn = vi.fn(() => 'result'); const { result } = renderHookSSR(() => useDebouncedState(fn, 100)); diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index cf697d93..e397bd4f 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -7,6 +7,8 @@ type DebounceOptions = { trailing?: boolean; }; +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + /** * @description * `useDebouncedState` is a React hook that manages state and returns a debounced setter. @@ -54,10 +56,11 @@ export function useDebouncedState( { leading = false, trailing = true }: DebounceOptions = {} ): [T, Dispatch>] { const [debouncedValue, setDebouncedValue] = useState(() => initialValue); - const lastForwardedRef = useRef(initialValue); + const lastForwardedRef = useRef(initialValue); function forwardChangedValue(value: SetStateAction) { if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; setDebouncedValue(value); return; } diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts index 5a51ca1e..cc4067fa 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -192,6 +192,19 @@ describe('useThrottledState', () => { expect(getValue()).toBe('zc'); }); + it('should apply a direct value after an updater without a render in between', () => { + const { result } = renderHookSSR(() => useThrottledState(0, 100, { leading: true })); + const { getValue, setValue } = actions(result); + + act(() => { + setValue(prev => prev + 1); + setValue(0); + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(0); + }); + it('should store a function initial value without calling it', () => { const fn = vi.fn(() => 'result'); const { result } = renderHookSSR(() => useThrottledState(fn, 100)); diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 4f6bc96d..87d92f86 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -7,6 +7,8 @@ type ThrottleOptions = { trailing?: boolean; }; +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + /** * @description * `useThrottledState` is a React hook that manages state and returns a throttled setter. @@ -51,10 +53,11 @@ export function useThrottledState( { leading = true, trailing = true }: ThrottleOptions = {} ): [T, Dispatch>] { const [throttledValue, setThrottledValue] = useState(() => initialValue); - const lastForwardedRef = useRef(initialValue); + const lastForwardedRef = useRef(initialValue); function forwardChangedValue(value: SetStateAction) { if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; setThrottledValue(value); return; }