Skip to content

feat(useCountdown): add useCountdown hook #233

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions src/hooks/useCountdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCountdown } from './useCountdown.ts';
252 changes: 252 additions & 0 deletions src/hooks/useCountdown/useCountdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useCountdown } from './useCountdown.ts';

describe('useCountdown', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('should initialize with countStart value', () => {
const { result } = renderHook(() => useCountdown(10, {}));

expect(result.current[0]).toBe(10);
});

it('should decrement count when countdown is started', () => {
const { result } = renderHook(() =>
useCountdown(10, {
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(9);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(8);
});

it('should increment count when isIncrement is true', () => {
const { result } = renderHook(() =>
useCountdown(0, {
countStop: 3,
isIncrement: true,
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(1);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(2);
});

it('should stop countdown when count equals countStop value', () => {
const { result } = renderHook(() =>
useCountdown(2, {
countStop: 0,
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(1);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(0);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(0);
});

it('should reset countdown when reset is called', () => {
const { result } = renderHook(() =>
useCountdown(10, {
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(2000);
});

expect(result.current[0]).toBe(8);

act(() => {
result.current[1].reset();
});

expect(result.current[0]).toBe(10);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(10);
});

it('should pause countdown when pause is called', () => {
const { result } = renderHook(() =>
useCountdown(10, {
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(9);

act(() => {
result.current[1].pause();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(9);
});

it('should resume countdown when resume is called', () => {
const { result } = renderHook(() =>
useCountdown(10, {
interval: 1000,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(9);

act(() => {
result.current[1].pause();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(9);

act(() => {
result.current[1].resume();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current[0]).toBe(8);
});

it('should call onCountChange when count changes', () => {
const onCountChange = vi.fn();
const { result } = renderHook(() =>
useCountdown(10, {
interval: 1000,
onCountChange,
})
);

expect(onCountChange).toHaveBeenCalledWith(10);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(onCountChange).toHaveBeenCalledWith(9);

act(() => {
vi.advanceTimersByTime(1000);
});

expect(onCountChange).toHaveBeenCalledWith(8);
});

it('should call onComplete when countdown reaches countStop', () => {
const onComplete = vi.fn();
const { result } = renderHook(() =>
useCountdown(2, {
countStop: 0,
interval: 1000,
onComplete,
})
);

act(() => {
result.current[1].start();
});

act(() => {
vi.advanceTimersByTime(1000);
});

expect(onComplete).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(1000);
});

expect(onComplete).toHaveBeenCalledTimes(1);
});
});
118 changes: 118 additions & 0 deletions src/hooks/useCountdown/useCountdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useCallback, useEffect } from 'react';

import { useBooleanState } from '../useBooleanState/index.ts';
import { useCounter } from '../useCounter/index.ts';
import { useInterval } from '../useInterval/index.ts';

type UseCountdownOptions = {
countStop?: number;
interval?: number;
isIncrement?: boolean;
onCountChange?: (count: number) => void;
onComplete?: () => void;
};

type UseCountdownControllers = {
start: () => void;
pause: () => void;
resume: () => void;
reset: () => void;
};

type UseCountdownReturn = [number, UseCountdownControllers];

/**
* @description
* `useCountdown` is a React hook that manages a countdown timer.
* It provides functions to start, pause, resume, and reset the countdown.
*
* @param {number} countStart - Starting value for the countdown
* @param {UseCountdownOptions} options - Countdown options
* @param {number} [options.countStop=0] - Value at which the countdown stops (default: 0)
* @param {number} [options.interval=1000] - Interval between counts (in milliseconds, default: 1000)
* @param {boolean} [options.isIncrement=false] - Whether to use increment mode (default: false, decreasing mode)
* @param {function} [options.onCountChange] - Callback function that is called whenever the count changes
* @param {function} [options.onComplete] - Callback function that is called when the countdown completes
*
* @returns {UseCountdownReturn} [count, controllers]
* - count `number` - Current count value
* - controllers `UseCountdownControllers` - Countdown control functions
* - start `() => void` - Function to start the countdown (resets first, then starts)
* - pause `() => void` - Function to pause the countdown
* - resume `() => void` - Function to resume the paused countdown
* - reset `() => void` - Function to reset the countdown (stops and returns to initial value)
*
* @example
* import { useCountdown } from 'react-simplikit';
*
* function Timer() {
* const [count, { start, pause, resume, reset }] = useCountdown(60, {
* countStop: 0,
* interval: 1000,
* onCountChange: (count) => console.log(`Current count: ${count}`),
* onComplete: () => console.log('Countdown complete!'),
* });
*
* return (
* <div>
* <p>Time remaining: {count} seconds</p>
* <button type="button" onClick={start}>Start</button>
* <button type="button" onClick={pause}>Pause</button>
* <button type="button" onClick={resume}>Resume</button>
* <button type="button" onClick={reset}>Reset</button>
* </div>
* );
* }
*/
export function useCountdown(
countStart: number,
{ countStop = 0, interval = 1000, isIncrement = false, onCountChange, onComplete }: UseCountdownOptions
): UseCountdownReturn {
const { count, increment: incrementCount, decrement: decrementCount, reset: resetCount } = useCounter(countStart);
const [isCountdownRunning, resumeCountdown, pauseCountdown] = useBooleanState(false);

const countdownCallback = useCallback(() => {
const nextCount = isIncrement ? count + 1 : count - 1;
const isCompleted = nextCount === countStop;

if (isIncrement) {
incrementCount();
} else {
decrementCount();
}

if (isCompleted) {
pauseCountdown();
onComplete?.();
}
}, [count, countStop, isIncrement, incrementCount, decrementCount, pauseCountdown, onComplete]);

const resetCountdown = useCallback(() => {
pauseCountdown();
resetCount();
}, [pauseCountdown, resetCount]);

const startCountdown = useCallback(() => {
resetCountdown();
resumeCountdown();
}, [resetCountdown, resumeCountdown]);

useInterval(countdownCallback, {
delay: interval,
enabled: isCountdownRunning,
});

useEffect(() => {
onCountChange?.(count);
}, [count, onCountChange]);

return [
count,
{
start: startCountdown,
pause: pauseCountdown,
resume: resumeCountdown,
reset: resetCountdown,
},
];
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts';
export { useBooleanState } from './hooks/useBooleanState/index.ts';
export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts';
export { useControlledState } from './hooks/useControlledState/index.ts';
export { useCountdown } from './hooks/useCountdown/index.ts';
export { useCounter } from './hooks/useCounter/index.ts';
export { useDebounce } from './hooks/useDebounce/index.ts';
export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts';
Expand Down
Loading