state-hooks
Version:
Essential set of React Hooks for convenient state management.
212 lines (204 loc) • 7.21 kB
JavaScript
import { useState, useRef, useEffect, useCallback } from 'react';
/**
* Tracks whether a value has changed over a relatively given period of time.
*
* @param value Props, state or any other calculated value.
* @param {number} groupingIntervalMs Time interval, in milliseconds, to group a batch of changes by.
* @returns `true` if the value has changed at least once over the given interval, or `false` otherwise.
*
* @example
* function Component() {
* const scrollCoords = useWindowScrollCoords();
* const isScrolling = useChanging(scrollCoords);
* // ...
* }
*/
function useChanging(value, groupingIntervalMs = 150) {
const [isChanging, setChanging] = useState(false);
const prevGroupingIntervalMsRef = useRef(0);
useEffect(() => {
// Prevent initial state from being true
if (groupingIntervalMs !== prevGroupingIntervalMsRef.current) {
prevGroupingIntervalMsRef.current = groupingIntervalMs;
}
else {
setChanging(true);
}
const timeoutID = setTimeout(() => setChanging(false), groupingIntervalMs);
return () => {
clearTimeout(timeoutID);
};
}, [groupingIntervalMs, value]);
return isChanging;
}
/**
* Tracks previous state of a value.
*
* @param value Props, state or any other calculated value.
* @returns Value from the previous render of the enclosing component.
*
* @example
* function Component() {
* const [count, setCount] = useState(0);
* const prevCount = usePrevious(count);
* // ...
* return `Now: ${count}, before: ${prevCount}`;
* }
*/
function usePrevious(value) {
// Source: https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Source: https://v8.dev/blog/react-cliff#value-representation
// eslint-disable-next-line import/prefer-default-export
const MAX_SMALL_INTEGER = 2 ** 30 - 1;
/**
* Records states of a value over time.
*
* @param value Props, state or any other calculated value.
* @param maxLength Maximum amount of states to store at once. Should be an integer greater than 1.
* @returns Results of state updates in chronological order.
*
* @example
* function Component() {
* const [count, setCount] = useState(0);
* const counts = useTimeline(count);
* // ...
* return `Now: ${count}, history: ${counts}`;
* }
*/
function useTimeline(value, maxLength = MAX_SMALL_INTEGER) {
const valuesRef = useRef([]);
const prevValue = usePrevious(value);
if (!Object.is(value, prevValue)) {
// Use immutable refs to behave like state variables
valuesRef.current = [...valuesRef.current, value];
}
if (valuesRef.current.length > maxLength) {
valuesRef.current.splice(0, valuesRef.current.length - maxLength);
}
return valuesRef.current;
}
/* eslint-disable jsdoc/valid-types */
/**
* Wraps a state hook to add boolean toggle functionality.
*
* @param useStateResult Return value of a state hook.
* @param useStateResult.0 Current state.
* @param useStateResult.1 State updater function.
* @returns State hook result extended with a `toggle` function.
*
* @example
* function Component() {
* const [isPressed, setPressed, togglePressed] = useToggle(
* useState<boolean>(false),
* );
* // ...
* return (
* <button type="button" aria-pressed={isPressed} onClick={togglePressed}>
* Toggle state
* </button>
* );
* }
*/
function useToggle([value, setValue]) {
const toggleValue = useCallback(() => {
setValue((prevValue) => !prevValue);
}, [setValue]);
return [value, setValue, toggleValue];
}
/**
* Wraps a state hook to add undo/redo functionality.
*
* @param useStateResult Return value of a state hook.
* @param useStateResult.0 Current state.
* @param useStateResult.1 State updater function.
* @param maxDeltas Maximum amount of state differences to store at once. Should be a positive integer.
* @returns State hook result extended with an object containing `undo`, `redo`, `past`, `future` and `jump`.
*
* @example
* function Component() {
* const [value, setValue, { undo, redo, past, future }] = useUndoable(
* useState(''),
* );
* // ...
* return (
* <>
* <button type="button" onClick={undo} disabled={past.length === 0}>
* Undo
* </button>
* <input value={value} onChange={(event) => setValue(event.target.value)} />
* <button type="button" onClick={redo} disabled={future.length === 0}>
* Redo
* </button>
* </>
* );
* }
*/
function useUndoable([value, setValue], maxDeltas = MAX_SMALL_INTEGER) {
// Source: https://redux.js.org/recipes/implementing-undo-history
const pastValuesRef = useRef([]);
const futureValuesRef = useRef([]);
const newSetValue = useCallback((update) => {
setValue((prevValue) => {
futureValuesRef.current = [];
pastValuesRef.current = [...pastValuesRef.current, prevValue];
return typeof update === 'function'
? update(prevValue)
: update;
});
}, [setValue]);
const jump = useCallback((delta) => {
if (delta < 0 && pastValuesRef.current.length >= -delta) {
// Undo
setValue((prevValue) => {
const nextValueIndex = pastValuesRef.current.length + delta;
const nextValue = pastValuesRef.current[nextValueIndex];
futureValuesRef.current = [
...pastValuesRef.current.slice(nextValueIndex + 1),
prevValue,
...futureValuesRef.current,
];
pastValuesRef.current = pastValuesRef.current.slice(0, delta);
return nextValue;
});
}
else if (delta > 0 && futureValuesRef.current.length >= delta) {
// Redo
setValue((prevValue) => {
const nextValue = futureValuesRef.current[delta - 1];
pastValuesRef.current = [
...pastValuesRef.current,
prevValue,
...futureValuesRef.current.slice(0, delta - 1),
];
futureValuesRef.current = futureValuesRef.current.slice(delta);
return nextValue;
});
}
}, [setValue]);
const undo = useCallback(() => jump(-1), [jump]);
const redo = useCallback(() => jump(+1), [jump]);
const deltas = pastValuesRef.current.length + futureValuesRef.current.length;
if (deltas > maxDeltas) {
futureValuesRef.current.splice(maxDeltas - deltas, MAX_SMALL_INTEGER);
pastValuesRef.current.splice(0, pastValuesRef.current.length - maxDeltas);
}
return [
value,
newSetValue,
{
undo,
redo,
past: pastValuesRef.current,
future: futureValuesRef.current,
jump,
},
];
}
export { useChanging, usePrevious, useTimeline, useToggle, useUndoable };
//# sourceMappingURL=index.js.map