diff --git a/docs/use-callback-const.md b/docs/use-callback-const.md
index 1d12202..996ca25 100644
--- a/docs/use-callback-const.md
+++ b/docs/use-callback-const.md
@@ -1,3 +1,16 @@
# `useCallbackConst`
-Documentation coming soon...
+Creates a callback with a constant value over the lifecycle of a component.
+
+## Example
+
+```tsx
+const immutableCallback = useCallbackConst(() => {
+ console.log(
+ `I don't have any local state dependencies
+ and I won't trigger unncessary re-renders.`
+ );
+});
+
+
+```
diff --git a/docs/use-callback-ref.md b/docs/use-callback-ref.md
index b6bc1d1..c69d3c8 100644
--- a/docs/use-callback-ref.md
+++ b/docs/use-callback-ref.md
@@ -1,3 +1,27 @@
# `useCallbackRef`
-Documentation coming soon...
+Converts a callback to a ref to avoid triggering re-renders when passed as a
+prop or avoid re-executing effects when passed as a dependency.
+
+Returns a new callback with a constant value over the lifecycle of a component.
+
+## Example
+
+```tsx
+const [state, setState] = useState(0);
+
+const logState = useCallbackRef(() => {
+ console.log('state:', state);
+});
+
+useEffect(() => {
+ logState();
+ // => "state: 1"
+ // => "state: 2"
+ // => "state: 3"
+ // => "state: 4"
+ // => ...
+}, [state]);
+
+
+```
diff --git a/docs/use-timer.md b/docs/use-timer.md
index 57bd166..7420445 100644
--- a/docs/use-timer.md
+++ b/docs/use-timer.md
@@ -2,19 +2,19 @@
Creates a timer that works inside the React component lifecycle. A timer can be configured with a total `length` (in milliseconds), a `tick` value (in milliseconds) representing the frequency a timer state updates, and an `autoStart` boolean value indicating whether to immediately initiate the countdown. Returns a `Timer` object with the following fields:
-- `start`: A callback function which starts a timer from scratch.
-- `pause`: A callback function which pauses the timer. This function has no effect if the timer has not yet started.
-- `resume`: A callback function which resumes the timer from a paused state. This function has no effect if the timer has not yet started.
-- `reset`: A callback function which resets the timer. An updated timer length and tick value can be provided as arguments. You will need to call `Timer.start()` to restart the countdown.
-- `getRemaining`: A callback function returning the amount of time (in milliseconds) remaining in the timer.
-- `getLength`: A callback function returning the total expected length of the timer (in milliseconds).
-- `isRunning`: A callback function returning a `boolean` indicating whether the timer is currently running.
-- `key`: A static value that updates whenever the timer state changes. You can provide this value to a `useEffect` dependency list to trigger effects.
+- `start`: A callback function which starts the timer ticking. If the timer is currently in a paused state, then it will resume based on the original, remaining length.
+- `pause`: A callback function which pauses the timer. This function has no effect if the timer has not yet started ticking.
+- `resume`: A callback function which resumes the timer from a paused state. This function has no effect if the timer has not yet started ticking.
+- `restart`: A callback function which restarts the timer. An updated timer length and tick value can be provided as arguments.
+- `getRemaining`: A callback function which returns the amount of time (in milliseconds) remaining in the timer. If the timer is idle, this function returns `0`.
+- `getTickCount`: A callback function returning the number of ticks that have accumulated since the start of the currently-running timer
+- `getStatus`: A callback function returning a string enum indicating the current timer state (one of: `"idle"`, `"running"`, `"paused"`, or `"expired"`).
+- `key`: A static value that updates whenever the underlying timer state changes. You can give this value to the dependency list `React.useEffect` to trigger an effect.
The `useTimer` module also exports additional utility hooks to integrate with timers:
-- `useTimerEffect`: Executes a side-effect each time the supplied timer "ticks".
-- `useTimerComplete`: Executes a side-effect when the supplied timer finishes its countdown.
+- `useTimerTick`: Executes a side-effect each time the supplied timer "ticks".
+- `useTimerExpire`: Executes a side-effect when the supplied timer finishes its countdown.
## Examples
@@ -29,19 +29,19 @@ const timer = useTimer({
```tsx
const timer = useTimer({ ..., tick: 1000 });
-useTimerEffect(timer, () => {
+useTimerTick(timer, () => {
// logs every 1 second!
console.log('tick tock tick tock');
-}, [/* effect dependencies */]);
+});
```
```tsx
const timer = useTimer({ ..., length: 5000 });
-useTimerComplete(timer, () => {
+useTimerExpire(timer, () => {
// logs after 5 seconds!
console.log('ding ding ding!');
-}, [/* effect dependencies */]);
+});
```
diff --git a/docs/use-value-ref.md b/docs/use-value-ref.md
index 0c7c944..52ea9c5 100644
--- a/docs/use-value-ref.md
+++ b/docs/use-value-ref.md
@@ -1,3 +1,11 @@
# `useValueRef`
-Documentation coming soon...
+Converts a given value to a ref to avoid triggering re-renders when passed as a
+prop or avoid re-executing effects when passed as a dependency.
+
+Returns a ref object with a constant value over the lifecycle of a component.
+
+## Example
+
+Example coming soon...
+
diff --git a/src/hooks/use-timer/index.ts b/src/hooks/use-timer/index.ts
index 3e524d7..0c3004a 100644
--- a/src/hooks/use-timer/index.ts
+++ b/src/hooks/use-timer/index.ts
@@ -1,204 +1,195 @@
-import { DependencyList, EffectCallback, useEffect, useMemo, useReducer, useRef } from 'react';
+import { EffectCallback, useEffect, useMemo, useRef } from 'react';
import { useCallbackConst } from '../use-callback-const';
-import { useCompare } from '../use-compare';
+import { useForceUpdate } from '../use-force-update';
+import { useValueRef } from '../use-value-ref';
+
+export type TimerHookStatus = 'idle' | 'running' | 'paused' | 'expired';
export interface TimerHook {
/**
- * Starts the timer.
+ * Starts the timer. If the timer is currently in a paused state, then it will
+ * resume based on the original remaining length.
*/
start: () => void;
/**
* Pauses the timer. This function has no effect if the timer has not yet
- * started.
+ * started ticking.
*/
pause: () => void;
/**
* Resumes the timer. This function has no effect if the timer has not yet
- * started.
+ * started ticking.
*/
resume: () => void;
/**
- * Resets the timer.
+ * Restarts the timer with new parameters.
*
* @param newLength - Optionally provide a new timer length (in milliseconds).
* @param newTick - Optionally provide a new tick length (in milliseconds).
*/
- reset: (newLength?: number, newTick?: number) => void;
+ restart: (options?: RestartTimerOptions) => void;
/**
* A function returning the amount of time (in milliseconds) remaining in the
- * timer.
+ * timer. If the timer is idle, this function returns `0`.
*/
getRemaining: () => number;
/**
- * A function returning the total expected length of the timer (in
- * milliseconds).
+ * A function returning the number of ticks that have accumulated since the
+ * start of the currently-running timer.
*/
- getLength: () => number;
+ getTickCount: () => number;
/**
- * A function returning `true` or `false` indicating whether the timer is
- * currently running.
+ * Returns a string enum indicating the current timer state
+ * (one of: `"idle"`, `"running"`, `"paused"`, or `"expired"`).
*/
- isRunning: () => boolean;
+ getStatus: () => TimerHookStatus;
/**
- * References a static value that updates whenever the timer state changes.
+ * A static value that updates whenever the underlying timer state changes.
* You can give this value to the dependency list `React.useEffect` to trigger
* an effect.
*/
key: number;
}
+export interface RestartTimerOptions {
+ length?: number;
+ tick?: number;
+}
+
+export interface UseTimerOptions {
+ length: number;
+ tick?: number;
+ autoStart?: boolean;
+}
+
/**
* Returns a timer that works inside the React lifecycle.
*
* @param length - The total length of the timer (in milliseconds).
* @param tick - The interval at which to update the timer (in milliseconds).
+ * @param autoStart - If `true`, the timer will immediately start ticking using
+ * the initially-provided `length` and `tick` values.
*/
-export function useTimer(options: { length: number; tick?: number; autoStart?: boolean }): TimerHook {
+export function useTimer(options: UseTimerOptions): TimerHook {
const { length, tick = 1000, autoStart = false } = options;
- const remaining = useRef(length);
- const isRunning = useRef(autoStart);
- const isStarted = useRef(autoStart);
- const lengthRef = useRef(length);
- const tickRef = useRef(tick);
-
- const [key, forceUpdate] = useReducer((x: number) => x + 1, 0) as [number, () => void];
+ const optionsRef = useValueRef({ length, tick });
- // Save the latest `tick` value.
- useEffect(() => {
- tickRef.current = tick;
- }, [tick]);
-
- // Save the latest `length` value.
- useEffect(() => {
- lengthRef.current = length;
- }, [length]);
+ const currentStatus = useRef(autoStart ? 'running' : 'idle');
+ const currentLength = useRef(autoStart ? length : 0);
+ const currentTick = useRef(tick);
+ const tickCount = useRef(0);
- // Build timer functionality callbacks.
+ const forceUpdate = useForceUpdate();
const start = useCallbackConst(() => {
- if (!isRunning.current && !isStarted.current) {
- isRunning.current = true;
- isStarted.current = true;
+ if (currentStatus.current === 'idle' || currentStatus.current === 'expired') {
+ currentLength.current = optionsRef.current.length || 0;
+ currentTick.current = optionsRef.current.tick || 0;
+ tickCount.current = 0;
+ currentStatus.current = 'running';
+ forceUpdate();
+ }
+
+ if (currentStatus.current === 'paused') {
+ currentStatus.current = 'running';
forceUpdate();
}
});
const pause = useCallbackConst(() => {
- if (isRunning.current) {
- isRunning.current = false;
+ if (currentStatus.current === 'running') {
+ currentStatus.current = 'paused';
forceUpdate();
}
});
const resume = useCallbackConst(() => {
- if (!isRunning.current && isStarted.current) {
- isRunning.current = true;
+ if (currentStatus.current === 'paused') {
+ currentStatus.current = 'running';
forceUpdate();
}
});
- const reset = useCallbackConst((newLength?: number, newTick?: number) => {
- if (newTick) tickRef.current = newTick;
- if (newLength) lengthRef.current = newLength;
- if (isRunning.current) isRunning.current = false;
- remaining.current = newLength || lengthRef.current;
- isStarted.current = false;
+ const restart = useCallbackConst((newOptions: RestartTimerOptions = {}) => {
+ currentLength.current = (newOptions.length ?? optionsRef.current.length) || 0;
+ currentTick.current = (newOptions.tick ?? optionsRef.current.tick) || 0;
+ tickCount.current = 0;
+ currentStatus.current = 'running';
forceUpdate();
});
// Update the timer.
useEffect(() => {
- if (isRunning.current && remaining.current > 0) {
+ if (currentStatus.current === 'running' && currentLength.current > 0) {
+ const interval = currentLength.current - currentTick.current < 0 ? currentLength.current : currentTick.current;
+
const id = setTimeout(() => {
- remaining.current -= tickRef.current;
+ currentLength.current -= interval;
+ tickCount.current++;
forceUpdate();
- }, tickRef.current);
+ }, interval);
return () => clearTimeout(id);
}
- if (isRunning.current && remaining.current === 0) {
- isRunning.current = false;
- forceUpdate();
- }
-
- if (isRunning.current && remaining.current < 0) {
- remaining.current = 0;
- isRunning.current = false;
+ if (currentStatus.current === 'running' && currentLength.current <= 0) {
+ currentLength.current = 0;
+ currentStatus.current = 'expired';
forceUpdate();
}
return undefined;
- }, [key]);
+ }, [forceUpdate.key]);
- return useMemo(
- () => ({
+ return useMemo(() => {
+ return {
start,
pause,
resume,
- reset,
- getRemaining: () => remaining.current,
- getLength: () => lengthRef.current,
- isRunning: () => isRunning.current,
- key,
- }),
- [start, pause, resume, reset, key],
- );
+ restart,
+ getRemaining: () => currentLength.current || 0,
+ getTickCount: () => tickCount.current || 0,
+ getStatus: () => currentStatus.current,
+ key: forceUpdate.key,
+ };
+ }, [forceUpdate.key]);
}
/**
* Execute an effect if the supplied timer ticks.
*
- * @param timer - The `TimerHook` object to base effects from.
+ * @param timer - The `TimerHook` object from which to trigger the effect.
* @param effect - Imperative function that can return a cleanup function.
- * @param deps - If present, effect will only activate if the values in the list change.
*/
-export function useTimerEffect(timer: TimerHook, effect: EffectCallback, deps: DependencyList = []) {
- const didTimerChange = useCompare(timer.getRemaining());
- const savedCallback = useRef(effect);
-
- useEffect(() => {
- savedCallback.current = effect;
- }, [effect]);
-
+export function useTimerTick(timer: TimerHook, effect: EffectCallback) {
useEffect(() => {
- if (timer.isRunning() && didTimerChange && timer.getRemaining() > 0) {
- return savedCallback.current();
+ if (timer.getTickCount() > 0) {
+ return effect();
}
-
return undefined;
- }, [timer.key, ...deps]);
+ }, [timer.getTickCount()]);
}
/**
- * Execute an effect if the supplied timer completes.
+ * Execute an effect if the supplied timer expires (completes its countdown).
*
- * @param timer - The `TimerHook` object to base effects from.
+ * @param timer - The `TimerHook` object from which to trigger the effect.
* @param effect - Imperative function that can return a cleanup function.
- * @param deps - If present, effect will only activate if the values in the list change.
*/
-export function useTimerComplete(timer: TimerHook, effect: EffectCallback, deps: DependencyList = []) {
- const didTimerChange = useCompare(timer.getRemaining());
- const savedCallback = useRef(effect);
-
+export function useTimerExpire(timer: TimerHook, effect: EffectCallback) {
useEffect(() => {
- savedCallback.current = effect;
- }, [effect]);
-
- useEffect(() => {
- if (didTimerChange && timer.getRemaining() <= 0) {
- return savedCallback.current();
+ if (timer.getStatus() === 'expired') {
+ return effect();
}
-
return undefined;
- }, [timer.key, ...deps]);
+ }, [timer.getStatus()]);
}