import type {SetStateAction} from 'react'; import {useMemo, useRef} from 'react'; import {useRerender} from '../useRerender/index.js'; import {useSyncedRef} from '../useSyncedRef/index.js'; import type {InitialState} from '../util/resolve-hook-state.js'; import {resolveHookState} from '../util/resolve-hook-state.js'; export type ListActions = { /** * Replaces the current list. */ set: (newList: SetStateAction) => void; /** * Adds an item or items to the end of the list. */ push: (...items: T[]) => void; /** * Replaces the item at the given index of the list. If the given index is out of bounds, empty * elements are appended to the list until the given item can be set to the given index. */ updateAt: (index: number, newItem: T) => void; /** * Inserts an item at the given index of the list. All items following the given index are shifted * one position. If the given index is out of bounds, empty elements are appended to the list until * the given item can be set to the given index. */ insertAt: (index: number, item: T) => void; /** * Replaces all items of the list that match the given predicate with the given item. */ update: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; /** * Replaces the first item of the list that matches the given predicate with the given item. */ updateFirst: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; /** * Replaces the first item of the list that matches the given predicate with the given item. If * none of the items match the predicate, the given item is pushed to the list. */ upsert: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; /** * Sorts the list with the given sorting function. If no sorting function is given, the default * Array.prototype.sort() sorting is used. */ sort: (compareFn?: (a: T, b: T) => number) => void; /** * Filters the list with the given filter function. */ // We're allowing the type of thisArg to be any, because we are following the Array.prototype.filter API. filter: (callbackFn: (value: T, index?: number, array?: T[]) => boolean, thisArg?: any) => void; /** * Removes the item at the given index of the list. All items following the given index will be * shifted. If the given index is out of the bounds of the list, the list will not be modified, * but a rerender will occur. */ removeAt: (index: number) => void; /** * Deletes all items of the list. */ clear: () => void; /** * Replaces the current list with the initial list given to this hook. */ reset: () => void; }; export function useList(initialList: InitialState): [T[], ListActions] { const initial = useSyncedRef(initialList); const list = useRef(resolveHookState(initial.current)); const rerender = useRerender(); const actions = useMemo( () => ({ set(newList: SetStateAction) { list.current = resolveHookState(newList, list.current); rerender(); }, push(...items: T[]) { actions.set((currentList: T[]) => [...currentList, ...items]); }, updateAt(index: number, newItem: T) { actions.set((currentList: T[]) => { const listCopy = [...currentList]; listCopy[index] = newItem; return listCopy; }); }, insertAt(index: number, newItem: T) { actions.set((currentList: T[]) => { const listCopy = [...currentList]; if (index >= listCopy.length) { listCopy[index] = newItem; } else { listCopy.splice(index, 0, newItem); } return listCopy; }); }, update(predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) { actions.set((currentList: T[]) => currentList.map((item: T) => (predicate(item, newItem) ? newItem : item))); }, updateFirst(predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) { const indexOfMatch = list.current.findIndex((item: T) => predicate(item, newItem)); const NO_MATCH = -1; if (indexOfMatch > NO_MATCH) { actions.updateAt(indexOfMatch, newItem); } }, upsert(predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) { const indexOfMatch = list.current.findIndex((item: T) => predicate(item, newItem)); const NO_MATCH = -1; if (indexOfMatch > NO_MATCH) { actions.updateAt(indexOfMatch, newItem); } else { actions.push(newItem); } }, sort(compareFn?: (a: T, b: T) => number) { actions.set((currentList: T[]) => currentList.toSorted(compareFn)); }, filter(callbackFn: (value: T, index: number, array: T[]) => boolean, thisArg?: never) { // eslint-disable-next-line unicorn/no-array-callback-reference,unicorn/no-array-method-this-argument actions.set((currentList: T[]) => [...currentList].filter(callbackFn, thisArg)); }, removeAt(index: number) { actions.set((currentList: T[]) => { const listCopy = [...currentList]; if (index < listCopy.length) { listCopy.splice(index, 1); } return listCopy; }); }, clear() { actions.set([]); }, reset() { actions.set([...resolveHookState(initial.current)]); }, }), [initial, rerender], ); return [list.current, actions]; }