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. diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index cdcae987..cf8a27e8 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. | @@ -110,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/useDebouncedState.md b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md new file mode 100644 index 00000000..0b8f0500 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md @@ -0,0 +1,105 @@ +# 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: Dispatch>]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` 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..4efffeb1 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useThrottledState.md @@ -0,0 +1,102 @@ +# 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: Dispatch>]; +``` + +### 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/useDebouncedState/es/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md new file mode 100644 index 00000000..3a6cb7ff --- /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: Dispatch>]; +``` + +### 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..a525e01f --- /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: Dispatch>]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```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..d4573f57 --- /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: Dispatch>]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```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..0b8f0500 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md @@ -0,0 +1,105 @@ +# 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: Dispatch>]; +``` + +### 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..54995abe --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -0,0 +1,279 @@ +import { Dispatch, SetStateAction } from 'react'; +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, Dispatch>] }) { + function getValue() { + return result.current[0]; + } + + function setValue(value: SetStateAction) { + 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('should return the initial value', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('should update only on the trailing edge by default debounce 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('should debounce continuous state updates', () => { + 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('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); + + 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 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); + + 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(() => 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('should discard a pending value when returning to the current state', () => { + 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('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 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)); + + 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(() => useDebouncedState(initialFn, 100)); + + const { getValue, setValue } = actions(result); + + setValue(() => nextFn); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(nextFn); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reset debouncing when debounceMs changes', () => { + 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('should reset debouncing when debounce options changes', () => { + 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('should cancel pending updates 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..e397bd4f --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -0,0 +1,84 @@ +import { Dispatch, SetStateAction, useRef, useState } from 'react'; + +import { useDebounce } from '../useDebounce/index.ts'; + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; +}; + +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + +/** + * @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: Dispatch>]} A tuple: + * - state `T` - The current state value; + * - setState `Dispatch>` - A debounced setter that accepts the next state value or an updater function; + * + * @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, Dispatch>] { + const [debouncedValue, setDebouncedValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; + setDebouncedValue(value); + return; + } + + 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: SetStateAction) => 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..d8fb727d --- /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: Dispatch>]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` 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..72194648 --- /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: Dispatch>]; +``` + +### 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..44c115d5 --- /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: Dispatch>]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```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..ff7a23ff --- /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: Dispatch>]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```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..4efffeb1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md @@ -0,0 +1,102 @@ +# 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: Dispatch>]; +``` + +### 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..cc4067fa --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -0,0 +1,278 @@ +import { Dispatch, SetStateAction } from 'react'; +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, Dispatch>] }) { + function getValue() { + return result.current[0]; + } + + function setValue(value: SetStateAction) { + 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 throttle options', () => { + 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 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 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)); + + 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..87d92f86 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -0,0 +1,81 @@ +import { Dispatch, SetStateAction, useRef, useState } from 'react'; + +import { useThrottle } from '../useThrottle/index.ts'; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + +/** + * @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: Dispatch>]} A tuple: + * - state `T` - The current state value; + * - setState `Dispatch>` - A throttled setter that accepts the next state value or an updater function; + * + * @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, Dispatch>] { + const [throttledValue, setThrottledValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; + setThrottledValue(value); + return; + } + + 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: SetStateAction) => 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..0a324143 --- /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: Dispatch>]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```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 ea9ff934..e1c00d3b 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'; @@ -44,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';