@octanejs/tanstack-pacer 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/package.json +62 -0
  4. package/src/async-batcher/index.ts +5 -0
  5. package/src/async-batcher/useAsyncBatchedCallback.ts +20 -0
  6. package/src/async-batcher/useAsyncBatcher.ts +116 -0
  7. package/src/async-debouncer/index.ts +5 -0
  8. package/src/async-debouncer/useAsyncDebouncedCallback.ts +25 -0
  9. package/src/async-debouncer/useAsyncDebouncer.ts +123 -0
  10. package/src/async-queuer/index.ts +5 -0
  11. package/src/async-queuer/useAsyncQueuedState.ts +30 -0
  12. package/src/async-queuer/useAsyncQueuer.ts +116 -0
  13. package/src/async-rate-limiter/index.ts +5 -0
  14. package/src/async-rate-limiter/useAsyncRateLimitedCallback.ts +27 -0
  15. package/src/async-rate-limiter/useAsyncRateLimiter.ts +120 -0
  16. package/src/async-retryer/index.ts +1 -0
  17. package/src/async-throttler/index.ts +5 -0
  18. package/src/async-throttler/useAsyncThrottledCallback.ts +24 -0
  19. package/src/async-throttler/useAsyncThrottler.ts +116 -0
  20. package/src/batcher/index.ts +5 -0
  21. package/src/batcher/useBatchedCallback.ts +20 -0
  22. package/src/batcher/useBatcher.ts +108 -0
  23. package/src/debouncer/index.ts +7 -0
  24. package/src/debouncer/useDebouncedCallback.ts +22 -0
  25. package/src/debouncer/useDebouncedState.ts +37 -0
  26. package/src/debouncer/useDebouncedValue.ts +41 -0
  27. package/src/debouncer/useDebouncer.ts +122 -0
  28. package/src/index.ts +63 -0
  29. package/src/internal.ts +45 -0
  30. package/src/provider/PacerProvider.tsrx +25 -0
  31. package/src/provider/PacerProvider.tsrx.d.ts +12 -0
  32. package/src/provider/context.ts +43 -0
  33. package/src/provider/index.ts +4 -0
  34. package/src/queuer/index.ts +6 -0
  35. package/src/queuer/useQueuedState.ts +27 -0
  36. package/src/queuer/useQueuedValue.ts +37 -0
  37. package/src/queuer/useQueuer.ts +110 -0
  38. package/src/rate-limiter/index.ts +7 -0
  39. package/src/rate-limiter/useRateLimitedCallback.ts +24 -0
  40. package/src/rate-limiter/useRateLimitedState.ts +34 -0
  41. package/src/rate-limiter/useRateLimitedValue.ts +38 -0
  42. package/src/rate-limiter/useRateLimiter.ts +119 -0
  43. package/src/throttler/index.ts +7 -0
  44. package/src/throttler/useThrottledCallback.ts +20 -0
  45. package/src/throttler/useThrottledState.ts +35 -0
  46. package/src/throttler/useThrottledValue.ts +38 -0
  47. package/src/throttler/useThrottler.ts +119 -0
  48. package/src/types/index.ts +1 -0
  49. package/src/utils/index.ts +1 -0
@@ -0,0 +1,120 @@
1
+ import { useEffect, useMemo, useState } from 'octane';
2
+ import { AsyncRateLimiter } from '@tanstack/pacer/async-rate-limiter';
3
+ import { shallow } from '@octanejs/tanstack-store';
4
+ import { useDefaultPacerOptions } from '../provider/context';
5
+ import { useSelectorSlot } from '../internal';
6
+ import type { Store } from '@octanejs/tanstack-store';
7
+ import type {
8
+ AsyncRateLimiterOptions,
9
+ AsyncRateLimiterState,
10
+ } from '@tanstack/pacer/async-rate-limiter';
11
+ import type { AnyAsyncFunction } from '@tanstack/pacer/types';
12
+
13
+ const subscribeSlot = Symbol.for('@octanejs/tanstack-pacer:useAsyncRateLimiter:Subscribe');
14
+ const stateSlot = Symbol.for('@octanejs/tanstack-pacer:useAsyncRateLimiter:state');
15
+
16
+ export interface ReactAsyncRateLimiterOptions<
17
+ TFn extends AnyAsyncFunction,
18
+ TSelected = {},
19
+ > extends AsyncRateLimiterOptions<TFn> {
20
+ /**
21
+ * Custom unmount behavior. Defaults to aborting in-flight executions.
22
+ */
23
+ onUnmount?: (rateLimiter: ReactAsyncRateLimiter<TFn, TSelected>) => void;
24
+ }
25
+
26
+ export interface ReactAsyncRateLimiter<TFn extends AnyAsyncFunction, TSelected = {}> extends Omit<
27
+ AsyncRateLimiter<TFn>,
28
+ 'store'
29
+ > {
30
+ /**
31
+ * Render-prop component subscribing to a slice of the rate limiter's state.
32
+ */
33
+ Subscribe: <TSelected>(props: {
34
+ selector: (state: AsyncRateLimiterState<TFn>) => TSelected;
35
+ children: ((state: TSelected) => unknown) | unknown;
36
+ }) => unknown;
37
+ /**
38
+ * Reactive state selected by the hook's `selector` (empty object without one).
39
+ */
40
+ readonly state: Readonly<TSelected>;
41
+ /**
42
+ * The underlying TanStack Store instance.
43
+ */
44
+ readonly store: Store<Readonly<AsyncRateLimiterState<TFn>>>;
45
+ }
46
+
47
+ /**
48
+ * An Octane hook that creates and manages an AsyncRateLimiter instance.
49
+ *
50
+ * **By default there are no reactive state subscriptions** — opt in by
51
+ * providing a `selector`; only then does the component re-render when the
52
+ * selected state values change.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const rateLimiter = useAsyncRateLimiter(fetchData, {
57
+ * limit: 5,
58
+ * window: 60000,
59
+ * });
60
+ * ```
61
+ */
62
+ export function useAsyncRateLimiter<TFn extends AnyAsyncFunction, TSelected = {}>(
63
+ fn: TFn,
64
+ options: ReactAsyncRateLimiterOptions<TFn, TSelected>,
65
+ selector: (state: AsyncRateLimiterState<TFn>) => TSelected = () => ({}) as TSelected,
66
+ ): ReactAsyncRateLimiter<TFn, TSelected> {
67
+ const mergedOptions = {
68
+ ...useDefaultPacerOptions().asyncRateLimiter,
69
+ ...options,
70
+ } as ReactAsyncRateLimiterOptions<TFn, TSelected>;
71
+ const [asyncRateLimiter] = useState(() => {
72
+ const asyncRateLimiterInstance = new AsyncRateLimiter<TFn>(
73
+ fn,
74
+ mergedOptions,
75
+ ) as unknown as ReactAsyncRateLimiter<TFn, TSelected>;
76
+
77
+ asyncRateLimiterInstance.Subscribe = function Subscribe<TSelected>(props: {
78
+ selector: (state: AsyncRateLimiterState<TFn>) => TSelected;
79
+ children: ((state: TSelected) => unknown) | unknown;
80
+ }) {
81
+ const selected = useSelectorSlot(
82
+ asyncRateLimiterInstance.store,
83
+ props.selector,
84
+ { compare: shallow },
85
+ subscribeSlot,
86
+ );
87
+
88
+ return typeof props.children === 'function'
89
+ ? (props.children as (state: TSelected) => unknown)(selected)
90
+ : props.children;
91
+ };
92
+
93
+ return asyncRateLimiterInstance;
94
+ });
95
+
96
+ asyncRateLimiter.fn = fn;
97
+ asyncRateLimiter.setOptions(mergedOptions);
98
+
99
+ const state = useSelectorSlot(asyncRateLimiter.store, selector, { compare: shallow }, stateSlot);
100
+
101
+ // Unmount cleanup only; empty deps keep teardown stable (as upstream).
102
+ useEffect(() => {
103
+ return () => {
104
+ if (mergedOptions.onUnmount) {
105
+ mergedOptions.onUnmount(asyncRateLimiter);
106
+ } else {
107
+ asyncRateLimiter.abort();
108
+ }
109
+ };
110
+ }, []);
111
+
112
+ return useMemo(
113
+ () =>
114
+ ({
115
+ ...asyncRateLimiter,
116
+ state,
117
+ }) as ReactAsyncRateLimiter<TFn, TSelected>, // omit `store` in favor of `state`
118
+ [asyncRateLimiter, state],
119
+ );
120
+ }
@@ -0,0 +1 @@
1
+ export * from '@tanstack/pacer/async-retryer';
@@ -0,0 +1,5 @@
1
+ // re-export everything from the core pacer package, BUT ONLY from the async-throttler module
2
+ export * from '@tanstack/pacer/async-throttler';
3
+
4
+ export * from './useAsyncThrottler';
5
+ export * from './useAsyncThrottledCallback';
@@ -0,0 +1,24 @@
1
+ import { useCallback } from 'octane';
2
+ import { useAsyncThrottler } from './useAsyncThrottler';
3
+ import type { ReactAsyncThrottlerOptions } from './useAsyncThrottler';
4
+ import type { AnyAsyncFunction } from '@tanstack/pacer/types';
5
+
6
+ /**
7
+ * An Octane hook that creates a throttled version of an async callback
8
+ * function.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const throttledSave = useAsyncThrottledCallback(saveApi, { wait: 1000 });
13
+ * ```
14
+ */
15
+ export function useAsyncThrottledCallback<TFn extends AnyAsyncFunction>(
16
+ fn: TFn,
17
+ options: ReactAsyncThrottlerOptions<TFn, {}>,
18
+ ): (...args: Parameters<TFn>) => Promise<ReturnType<TFn>> {
19
+ const asyncThrottledFn = useAsyncThrottler(fn, options).maybeExecute;
20
+ return useCallback(
21
+ (...args) => asyncThrottledFn(...args) as Promise<ReturnType<TFn>>,
22
+ [asyncThrottledFn],
23
+ );
24
+ }
@@ -0,0 +1,116 @@
1
+ import { useEffect, useMemo, useState } from 'octane';
2
+ import { AsyncThrottler } from '@tanstack/pacer/async-throttler';
3
+ import { shallow } from '@octanejs/tanstack-store';
4
+ import { useDefaultPacerOptions } from '../provider/context';
5
+ import { useSelectorSlot } from '../internal';
6
+ import type { Store } from '@octanejs/tanstack-store';
7
+ import type { AsyncThrottlerOptions, AsyncThrottlerState } from '@tanstack/pacer/async-throttler';
8
+ import type { AnyAsyncFunction } from '@tanstack/pacer/types';
9
+
10
+ const subscribeSlot = Symbol.for('@octanejs/tanstack-pacer:useAsyncThrottler:Subscribe');
11
+ const stateSlot = Symbol.for('@octanejs/tanstack-pacer:useAsyncThrottler:state');
12
+
13
+ export interface ReactAsyncThrottlerOptions<
14
+ TFn extends AnyAsyncFunction,
15
+ TSelected = {},
16
+ > extends AsyncThrottlerOptions<TFn> {
17
+ /**
18
+ * Custom unmount behavior. Defaults to cancelling pending executions and
19
+ * aborting in-flight ones.
20
+ */
21
+ onUnmount?: (throttler: ReactAsyncThrottler<TFn, TSelected>) => void;
22
+ }
23
+
24
+ export interface ReactAsyncThrottler<TFn extends AnyAsyncFunction, TSelected = {}> extends Omit<
25
+ AsyncThrottler<TFn>,
26
+ 'store'
27
+ > {
28
+ /**
29
+ * Render-prop component subscribing to a slice of the throttler's state.
30
+ */
31
+ Subscribe: <TSelected>(props: {
32
+ selector: (state: AsyncThrottlerState<TFn>) => TSelected;
33
+ children: ((state: TSelected) => unknown) | unknown;
34
+ }) => unknown;
35
+ /**
36
+ * Reactive state selected by the hook's `selector` (empty object without one).
37
+ */
38
+ readonly state: Readonly<TSelected>;
39
+ /**
40
+ * The underlying TanStack Store instance.
41
+ */
42
+ readonly store: Store<Readonly<AsyncThrottlerState<TFn>>>;
43
+ }
44
+
45
+ /**
46
+ * An Octane hook that creates and manages an AsyncThrottler instance.
47
+ *
48
+ * **By default there are no reactive state subscriptions** — opt in by
49
+ * providing a `selector`; only then does the component re-render when the
50
+ * selected state values change.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * const throttler = useAsyncThrottler(async (v) => save(v), { wait: 1000 });
55
+ * ```
56
+ */
57
+ export function useAsyncThrottler<TFn extends AnyAsyncFunction, TSelected = {}>(
58
+ fn: TFn,
59
+ options: ReactAsyncThrottlerOptions<TFn, TSelected>,
60
+ selector: (state: AsyncThrottlerState<TFn>) => TSelected = () => ({}) as TSelected,
61
+ ): ReactAsyncThrottler<TFn, TSelected> {
62
+ const mergedOptions = {
63
+ ...useDefaultPacerOptions().asyncThrottler,
64
+ ...options,
65
+ } as ReactAsyncThrottlerOptions<TFn, TSelected>;
66
+ const [asyncThrottler] = useState(() => {
67
+ const asyncThrottlerInstance = new AsyncThrottler<TFn>(
68
+ fn,
69
+ mergedOptions,
70
+ ) as unknown as ReactAsyncThrottler<TFn, TSelected>;
71
+
72
+ asyncThrottlerInstance.Subscribe = function Subscribe<TSelected>(props: {
73
+ selector: (state: AsyncThrottlerState<TFn>) => TSelected;
74
+ children: ((state: TSelected) => unknown) | unknown;
75
+ }) {
76
+ const selected = useSelectorSlot(
77
+ asyncThrottlerInstance.store,
78
+ props.selector,
79
+ { compare: shallow },
80
+ subscribeSlot,
81
+ );
82
+
83
+ return typeof props.children === 'function'
84
+ ? (props.children as (state: TSelected) => unknown)(selected)
85
+ : props.children;
86
+ };
87
+
88
+ return asyncThrottlerInstance;
89
+ });
90
+
91
+ asyncThrottler.fn = fn;
92
+ asyncThrottler.setOptions(mergedOptions);
93
+
94
+ const state = useSelectorSlot(asyncThrottler.store, selector, { compare: shallow }, stateSlot);
95
+
96
+ // Unmount cleanup only; empty deps keep teardown stable (as upstream).
97
+ useEffect(() => {
98
+ return () => {
99
+ if (mergedOptions.onUnmount) {
100
+ mergedOptions.onUnmount(asyncThrottler);
101
+ } else {
102
+ asyncThrottler.cancel();
103
+ asyncThrottler.abort();
104
+ }
105
+ };
106
+ }, []);
107
+
108
+ return useMemo(
109
+ () =>
110
+ ({
111
+ ...asyncThrottler,
112
+ state,
113
+ }) as ReactAsyncThrottler<TFn, TSelected>, // omit `store` in favor of `state`
114
+ [asyncThrottler, state],
115
+ );
116
+ }
@@ -0,0 +1,5 @@
1
+ // re-export everything from the core pacer package, BUT ONLY from the batcher module
2
+ export * from '@tanstack/pacer/batcher';
3
+
4
+ export * from './useBatcher';
5
+ export * from './useBatchedCallback';
@@ -0,0 +1,20 @@
1
+ import { useCallback } from 'octane';
2
+ import { useBatcher } from './useBatcher';
3
+ import type { ReactBatcherOptions } from './useBatcher';
4
+
5
+ /**
6
+ * An Octane hook that creates a batched version of a callback function.
7
+ * Calling the returned function adds the item to the batch.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * const addToBatch = useBatchedCallback(processBatch, { maxSize: 10 });
12
+ * ```
13
+ */
14
+ export function useBatchedCallback<TValue>(
15
+ fn: (items: Array<TValue>) => void,
16
+ options: ReactBatcherOptions<TValue, {}>,
17
+ ): (item: TValue) => void {
18
+ const batchedFn = useBatcher(fn, options).addItem;
19
+ return useCallback((item: TValue) => batchedFn(item), [batchedFn]);
20
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useMemo, useState } from 'octane';
2
+ import { Batcher } from '@tanstack/pacer/batcher';
3
+ import { shallow } from '@octanejs/tanstack-store';
4
+ import { useDefaultPacerOptions } from '../provider/context';
5
+ import { useSelectorSlot } from '../internal';
6
+ import type { Store } from '@octanejs/tanstack-store';
7
+ import type { BatcherOptions, BatcherState } from '@tanstack/pacer/batcher';
8
+
9
+ const subscribeSlot = Symbol.for('@octanejs/tanstack-pacer:useBatcher:Subscribe');
10
+ const stateSlot = Symbol.for('@octanejs/tanstack-pacer:useBatcher:state');
11
+
12
+ export interface ReactBatcherOptions<TValue, TSelected = {}> extends BatcherOptions<TValue> {
13
+ /**
14
+ * Custom unmount behavior. Defaults to cancelling any pending batch.
15
+ */
16
+ onUnmount?: (batcher: ReactBatcher<TValue, TSelected>) => void;
17
+ }
18
+
19
+ export interface ReactBatcher<TValue, TSelected = {}> extends Omit<Batcher<TValue>, 'store'> {
20
+ /**
21
+ * Render-prop component subscribing to a slice of the batcher's state.
22
+ */
23
+ Subscribe: <TSelected>(props: {
24
+ selector: (state: BatcherState<TValue>) => TSelected;
25
+ children: ((state: TSelected) => unknown) | unknown;
26
+ }) => unknown;
27
+ /**
28
+ * Reactive state selected by the hook's `selector` (empty object without one).
29
+ */
30
+ readonly state: Readonly<TSelected>;
31
+ /**
32
+ * The underlying TanStack Store instance.
33
+ */
34
+ readonly store: Store<Readonly<BatcherState<TValue>>>;
35
+ }
36
+
37
+ /**
38
+ * An Octane hook that creates and manages a Batcher instance — collects items
39
+ * and processes them together by size or time threshold.
40
+ *
41
+ * **By default there are no reactive state subscriptions** — opt in by
42
+ * providing a `selector`; only then does the component re-render when the
43
+ * selected state values change.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * const batcher = useBatcher(processBatch, { maxSize: 10, wait: 2000 });
48
+ * ```
49
+ */
50
+ export function useBatcher<TValue, TSelected = {}>(
51
+ fn: (items: Array<TValue>) => void,
52
+ options: ReactBatcherOptions<TValue, TSelected> = {},
53
+ selector: (state: BatcherState<TValue>) => TSelected = () => ({}) as TSelected,
54
+ ): ReactBatcher<TValue, TSelected> {
55
+ const mergedOptions = {
56
+ ...useDefaultPacerOptions().batcher,
57
+ ...options,
58
+ } as ReactBatcherOptions<TValue, TSelected>;
59
+ const [batcher] = useState(() => {
60
+ const batcherInstance = new Batcher<TValue>(fn, mergedOptions) as unknown as ReactBatcher<
61
+ TValue,
62
+ TSelected
63
+ >;
64
+
65
+ batcherInstance.Subscribe = function Subscribe<TSelected>(props: {
66
+ selector: (state: BatcherState<TValue>) => TSelected;
67
+ children: ((state: TSelected) => unknown) | unknown;
68
+ }) {
69
+ const selected = useSelectorSlot(
70
+ batcherInstance.store,
71
+ props.selector,
72
+ { compare: shallow },
73
+ subscribeSlot,
74
+ );
75
+
76
+ return typeof props.children === 'function'
77
+ ? (props.children as (state: TSelected) => unknown)(selected)
78
+ : props.children;
79
+ };
80
+
81
+ return batcherInstance;
82
+ });
83
+
84
+ batcher.fn = fn;
85
+ batcher.setOptions(mergedOptions);
86
+
87
+ // Unmount cleanup only; empty deps keep teardown stable (as upstream).
88
+ useEffect(() => {
89
+ return () => {
90
+ if (mergedOptions.onUnmount) {
91
+ mergedOptions.onUnmount(batcher);
92
+ } else {
93
+ batcher.cancel();
94
+ }
95
+ };
96
+ }, []);
97
+
98
+ const state = useSelectorSlot(batcher.store, selector, { compare: shallow }, stateSlot);
99
+
100
+ return useMemo(
101
+ () =>
102
+ ({
103
+ ...batcher,
104
+ state,
105
+ }) as ReactBatcher<TValue, TSelected>, // omit `store` in favor of `state`
106
+ [batcher, state],
107
+ );
108
+ }
@@ -0,0 +1,7 @@
1
+ // re-export everything from the core pacer package, BUT ONLY from the debouncer module
2
+ export * from '@tanstack/pacer/debouncer';
3
+
4
+ export * from './useDebouncedCallback';
5
+ export * from './useDebouncedState';
6
+ export * from './useDebouncedValue';
7
+ export * from './useDebouncer';
@@ -0,0 +1,22 @@
1
+ import { useCallback } from 'octane';
2
+ import { useDebouncer } from './useDebouncer';
3
+ import type { ReactDebouncerOptions } from './useDebouncer';
4
+ import type { AnyFunction } from '@tanstack/pacer/types';
5
+
6
+ /**
7
+ * An Octane hook that creates a debounced version of a callback function.
8
+ * The returned function delays invoking the callback until after the
9
+ * specified wait time has elapsed since its last invocation.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * const debouncedSearch = useDebouncedCallback(performSearch, { wait: 500 });
14
+ * ```
15
+ */
16
+ export function useDebouncedCallback<TFn extends AnyFunction>(
17
+ fn: TFn,
18
+ options: ReactDebouncerOptions<TFn, {}>,
19
+ ): (...args: Parameters<TFn>) => void {
20
+ const debouncedFn = useDebouncer(fn, options).maybeExecute;
21
+ return useCallback((...args) => debouncedFn(...args), [debouncedFn]);
22
+ }
@@ -0,0 +1,37 @@
1
+ import { useState } from 'octane';
2
+ import { useDebouncer } from './useDebouncer';
3
+ import type { ReactDebouncer, ReactDebouncerOptions } from './useDebouncer';
4
+ import type { Dispatch, SetStateAction } from '../internal';
5
+ import type { DebouncerState } from '@tanstack/pacer/debouncer';
6
+
7
+ /**
8
+ * An Octane hook that creates a debounced state value, combining `useState`
9
+ * with debouncing functionality. Returns a tuple of the current debounced
10
+ * value, a debounced updater, and the debouncer instance.
11
+ *
12
+ * **By default there are no reactive state subscriptions** — opt in by
13
+ * providing a `selector` (see {@link useDebouncer}).
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const [searchTerm, setSearchTerm, debouncer] = useDebouncedState('', {
18
+ * wait: 500,
19
+ * });
20
+ * ```
21
+ */
22
+ export function useDebouncedState<
23
+ TValue,
24
+ TSelected = DebouncerState<Dispatch<SetStateAction<TValue>>>,
25
+ >(
26
+ value: TValue,
27
+ options: ReactDebouncerOptions<Dispatch<SetStateAction<TValue>>, TSelected>,
28
+ selector?: (state: DebouncerState<Dispatch<SetStateAction<TValue>>>) => TSelected,
29
+ ): [
30
+ TValue,
31
+ Dispatch<SetStateAction<TValue>>,
32
+ ReactDebouncer<Dispatch<SetStateAction<TValue>>, TSelected>,
33
+ ] {
34
+ const [debouncedValue, setDebouncedValue] = useState(value);
35
+ const debouncer = useDebouncer(setDebouncedValue, options, selector);
36
+ return [debouncedValue, debouncer.maybeExecute, debouncer];
37
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect } from 'octane';
2
+ import { useDebouncedState } from './useDebouncedState';
3
+ import type { ReactDebouncer, ReactDebouncerOptions } from './useDebouncer';
4
+ import type { Dispatch, SetStateAction } from '../internal';
5
+ import type { DebouncerState } from '@tanstack/pacer/debouncer';
6
+
7
+ /**
8
+ * An Octane hook that creates a debounced value that updates only after a
9
+ * specified delay. Unlike `useDebouncedState`, this hook automatically tracks
10
+ * changes to the input value and updates the debounced value accordingly.
11
+ *
12
+ * **By default there are no reactive state subscriptions** — opt in by
13
+ * providing a `selector` (see {@link useDebouncer}).
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const [debouncedQuery, debouncer] = useDebouncedValue(searchQuery, {
18
+ * wait: 500, // Wait 500ms after last change
19
+ * });
20
+ * ```
21
+ */
22
+ export function useDebouncedValue<
23
+ TValue,
24
+ TSelected = DebouncerState<Dispatch<SetStateAction<TValue>>>,
25
+ >(
26
+ value: TValue,
27
+ options: ReactDebouncerOptions<Dispatch<SetStateAction<TValue>>, TSelected>,
28
+ selector?: (state: DebouncerState<Dispatch<SetStateAction<TValue>>>) => TSelected,
29
+ ): [TValue, ReactDebouncer<Dispatch<SetStateAction<TValue>>, TSelected>] {
30
+ const [debouncedValue, setDebouncedValue, debouncer] = useDebouncedState(
31
+ value,
32
+ options,
33
+ selector,
34
+ );
35
+
36
+ useEffect(() => {
37
+ setDebouncedValue(value);
38
+ }, [value, setDebouncedValue]);
39
+
40
+ return [debouncedValue, debouncer];
41
+ }
@@ -0,0 +1,122 @@
1
+ import { useEffect, useMemo, useState } from 'octane';
2
+ import { Debouncer } from '@tanstack/pacer/debouncer';
3
+ import { shallow } from '@octanejs/tanstack-store';
4
+ import { useDefaultPacerOptions } from '../provider/context';
5
+ import { useSelectorSlot } from '../internal';
6
+ import type { Store } from '@octanejs/tanstack-store';
7
+ import type { DebouncerOptions, DebouncerState } from '@tanstack/pacer/debouncer';
8
+ import type { AnyFunction } from '@tanstack/pacer/types';
9
+
10
+ const subscribeSlot = Symbol.for('@octanejs/tanstack-pacer:useDebouncer:Subscribe');
11
+ const stateSlot = Symbol.for('@octanejs/tanstack-pacer:useDebouncer:state');
12
+
13
+ export interface ReactDebouncerOptions<
14
+ TFn extends AnyFunction,
15
+ TSelected = {},
16
+ > extends DebouncerOptions<TFn> {
17
+ /**
18
+ * Custom unmount behavior. Defaults to cancelling any pending execution.
19
+ */
20
+ onUnmount?: (debouncer: ReactDebouncer<TFn, TSelected>) => void;
21
+ }
22
+
23
+ export interface ReactDebouncer<TFn extends AnyFunction, TSelected = {}> extends Omit<
24
+ Debouncer<TFn>,
25
+ 'store'
26
+ > {
27
+ /**
28
+ * Render-prop component subscribing to a slice of the debouncer's state.
29
+ */
30
+ Subscribe: <TSelected>(props: {
31
+ selector: (state: DebouncerState<TFn>) => TSelected;
32
+ children: ((state: TSelected) => unknown) | unknown;
33
+ }) => unknown;
34
+ /**
35
+ * Reactive state selected by the hook's `selector` (empty object without one).
36
+ */
37
+ readonly state: Readonly<TSelected>;
38
+ /**
39
+ * The underlying TanStack Store instance.
40
+ */
41
+ readonly store: Store<Readonly<DebouncerState<TFn>>>;
42
+ }
43
+
44
+ /**
45
+ * An Octane hook that creates and manages a Debouncer instance.
46
+ *
47
+ * The debouncer delays function execution until after a specified wait time
48
+ * has elapsed since the last call.
49
+ *
50
+ * ## State Management and Selector
51
+ *
52
+ * Uses TanStack Store for reactive state management. **By default there are no
53
+ * reactive state subscriptions** — opt in by providing a `selector`; only then
54
+ * does the component re-render when the selected state values change.
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * // Opt-in to re-render when isPending changes
59
+ * const debouncer = useDebouncer(setValue, { wait: 500 }, (state) => ({
60
+ * isPending: state.isPending,
61
+ * }));
62
+ * ```
63
+ */
64
+ export function useDebouncer<TFn extends AnyFunction, TSelected = {}>(
65
+ fn: TFn,
66
+ options: ReactDebouncerOptions<TFn, TSelected>,
67
+ selector: (state: DebouncerState<TFn>) => TSelected = () => ({}) as TSelected,
68
+ ): ReactDebouncer<TFn, TSelected> {
69
+ const mergedOptions = {
70
+ ...useDefaultPacerOptions().debouncer,
71
+ ...options,
72
+ } as ReactDebouncerOptions<TFn, TSelected>;
73
+ const [debouncer] = useState(() => {
74
+ const debouncerInstance = new Debouncer(fn, mergedOptions) as unknown as ReactDebouncer<
75
+ TFn,
76
+ TSelected
77
+ >;
78
+
79
+ debouncerInstance.Subscribe = function Subscribe<TSelected>(props: {
80
+ selector: (state: DebouncerState<TFn>) => TSelected;
81
+ children: ((state: TSelected) => unknown) | unknown;
82
+ }) {
83
+ const selected = useSelectorSlot(
84
+ debouncerInstance.store,
85
+ props.selector,
86
+ { compare: shallow },
87
+ subscribeSlot,
88
+ );
89
+
90
+ return typeof props.children === 'function'
91
+ ? (props.children as (state: TSelected) => unknown)(selected)
92
+ : props.children;
93
+ };
94
+
95
+ return debouncerInstance;
96
+ });
97
+
98
+ debouncer.fn = fn;
99
+ debouncer.setOptions(mergedOptions);
100
+
101
+ // Unmount cleanup only; empty deps keep teardown stable (as upstream).
102
+ useEffect(() => {
103
+ return () => {
104
+ if (mergedOptions.onUnmount) {
105
+ mergedOptions.onUnmount(debouncer);
106
+ } else {
107
+ debouncer.cancel();
108
+ }
109
+ };
110
+ }, []);
111
+
112
+ const state = useSelectorSlot(debouncer.store, selector, { compare: shallow }, stateSlot);
113
+
114
+ return useMemo(
115
+ () =>
116
+ ({
117
+ ...debouncer,
118
+ state,
119
+ }) as ReactDebouncer<TFn, TSelected>, // omit `store` in favor of `state`
120
+ [debouncer, state],
121
+ );
122
+ }