Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/hooks/useDelayState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';
import raf from '../raf';
import useEvent from './useEvent';

export type DelayConfig =
{ frame: number; ms?: never } | { frame?: never; ms: number };

export type SetDelayState<T> = (
nextValue: React.SetStateAction<T>,
/** `true` updates immediately. `false` delays the update by one frame. */
immediatelyOrDelay?: boolean | DelayConfig,
) => void;

type DelayInfo =
| [isRaf: true, delay: number]
| [isRaf: false, delay: ReturnType<typeof setTimeout>];

/**
* Similar to `useState`, but updates on the next frame by default.
* Pending updates are always replaced by the latest one.
*/
export default function useDelayState<T>(
defaultValue: T | (() => T),
): [T, SetDelayState<T>] {
const [value, setValue] = React.useState(defaultValue);
const delayRef = React.useRef<DelayInfo | null>(null);

const cancelPending = useEvent(() => {
if (delayRef.current) {
const [isRaf, delay] = delayRef.current;
if (isRaf) {
raf.cancel(delay);
} else {
clearTimeout(delay);
}
delayRef.current = null;
}
});

const setDelayValue = useEvent<SetDelayState<T>>(
(nextValue, immediatelyOrDelay) => {
const delayConfig = immediatelyOrDelay || { frame: 1 };
cancelPending();

if (delayConfig === true) {
setValue(nextValue);
} else if ('ms' in delayConfig) {
delayRef.current = [
false,
setTimeout(() => setValue(nextValue), delayConfig.ms),
];
} else {
delayRef.current = [
true,
raf(() => setValue(nextValue), delayConfig.frame),
];
}
},
);

return [value, setDelayValue];
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { default as useEvent } from './hooks/useEvent';
export { default as useMergedState } from './hooks/useMergedState';
export { default as useControlledState } from './hooks/useControlledState';
export { default as useDelayState } from './hooks/useDelayState';
export type { DelayConfig, SetDelayState } from './hooks/useDelayState';
export { default as useId, getId } from './hooks/useId';
export {
default as useLayoutEffect,
Expand Down
133 changes: 133 additions & 0 deletions tests/useDelayState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { act, renderHook } from '@testing-library/react';
import useDelayState from '../src/hooks/useDelayState';

describe('useDelayState', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

it('updates on the next frame by default', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1);
});
expect(result.current[0]).toBe(0);

act(() => {
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(1);
});

it('updates immediately', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, true);
});
expect(result.current[0]).toBe(1);
});

it('delays when immediate is false', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, false);
});
expect(result.current[0]).toBe(0);

act(() => {
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(1);
});

it('supports frame delay', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, { frame: 2 });
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(0);

act(() => {
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(1);
});

it('supports millisecond delay', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, { ms: 100 });
jest.advanceTimersByTime(99);
});
expect(result.current[0]).toBe(0);

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current[0]).toBe(1);
});

it('uses the latest pending update', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, { ms: 100 });
result.current[1](2);
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(2);

act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current[0]).toBe(2);

act(() => {
result.current[1](3, { frame: 2 });
result.current[1](4, { ms: 100 });
jest.advanceTimersByTime(32);
});
expect(result.current[0]).toBe(2);

act(() => {
jest.advanceTimersByTime(68);
});
expect(result.current[0]).toBe(4);
});

it('cancels a pending update when updating immediately', () => {
const { result } = renderHook(() => useDelayState(0));

act(() => {
result.current[1](1, { frame: 2 });
result.current[1](2, true);
});
expect(result.current[0]).toBe(2);

act(() => {
jest.advanceTimersByTime(32);
});
expect(result.current[0]).toBe(2);
});

it('supports updater function', () => {
const { result } = renderHook(() => useDelayState(1));

act(() => {
result.current[1](value => value + 1);
jest.advanceTimersByTime(16);
});
expect(result.current[0]).toBe(2);
});
});
Loading