From 604457ccdf4adcbad93d06439112535f3c0e98b2 Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 21 Oct 2022 10:20:35 -0600 Subject: [PATCH 1/2] Update docs --- docs/use-callback-const.md | 15 ++++++++++++++- docs/use-callback-ref.md | 26 +++++++++++++++++++++++++- docs/use-value-ref.md | 10 +++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) 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-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... + From 3c5afd102e9c6ba8165f8cabf94c409074ddd243 Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 21 Oct 2022 12:24:29 -0600 Subject: [PATCH 2/2] Rework 'useTimer' --- docs/use-timer.md | 28 +++--- src/hooks/use-timer/index.ts | 185 +++++++++++++++++------------------ 2 files changed, 102 insertions(+), 111 deletions(-) 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/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()]); }