diff --git a/README.md b/README.md
index 8bcfe9111..75827d86a 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,8 @@ Coming from `react-use`? Check out our
— Tracks a numeric value and offers functions for manipulating it.
- [**`useDebouncedState`**](https://react-hookz.github.io/web/?path=/docs/state-usedebouncedstate--example)
— Like `useSafeState` but its state setter is debounced.
+ - [**`useList`**](https://react-hookz.github.io/web/?path=/docs/state-uselist--example)
+ — Tracks a list and offers functions for manipulating it.
- [**`useMap`**](https://react-hookz.github.io/web/?path=/docs/state-usemap--example) — Tracks the
state of a `Map`.
- [**`useMediatedState`**](https://react-hookz.github.io/web/?path=/docs/state-usemediatedstate--example)
diff --git a/src/__docs__/migrating-from-react-use.story.mdx b/src/__docs__/migrating-from-react-use.story.mdx
index 0d0610ce2..5b62b2da2 100644
--- a/src/__docs__/migrating-from-react-use.story.mdx
+++ b/src/__docs__/migrating-from-react-use.story.mdx
@@ -658,7 +658,7 @@ Use [useCounter](/docs/state-usecounter--example) instead.
#### useList
-Not implemented yet
+Implemented as [useList](/docs/state-uselist--example).
#### useUpsert
diff --git a/src/index.ts b/src/index.ts
index a73480e1e..431727885 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,6 +26,7 @@ export { useLifecycleLogger } from './useLifecycleLogger/useLifecycleLogger';
// State
export { useDebouncedState } from './useDebouncedState/useDebouncedState';
+export { useList } from './useList/useList';
export { useMap } from './useMap/useMap';
export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
diff --git a/src/useList/__docs__/example.stories.tsx b/src/useList/__docs__/example.stories.tsx
new file mode 100644
index 000000000..b1dea34b3
--- /dev/null
+++ b/src/useList/__docs__/example.stories.tsx
@@ -0,0 +1,57 @@
+import * as React from 'react';
+import { useList } from '../..';
+
+export const Example: React.FC = () => {
+ const [
+ list,
+ {
+ set,
+ push,
+ updateAt,
+ insertAt,
+ update,
+ updateFirst,
+ upsert,
+ sort,
+ filter,
+ removeAt,
+ clear,
+ reset,
+ },
+ ] = useList([1, 2, 3, 4, 5]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{JSON.stringify(list, null, 2)}
+
+ );
+};
diff --git a/src/useList/__docs__/story.mdx b/src/useList/__docs__/story.mdx
new file mode 100644
index 000000000..3c753259b
--- /dev/null
+++ b/src/useList/__docs__/story.mdx
@@ -0,0 +1,60 @@
+import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
+import { Example } from './example.stories';
+import { ImportPath } from '../../storybookUtil/ImportPath';
+
+
+
+# useList
+
+Tracks a list and offers functions for manipulating it.
+
+Manipulating the list directly will not cause a rerender. Instead, use the offered functions.
+
+> **_This hook provides a stable API, meaning the returned functions do not change between renders_**
+
+#### Example
+
+
+
+## Reference
+
+```ts
+function useList(initialList: IInitialState): [T[], ListActions];
+```
+
+#### Importing
+
+
+
+#### Arguments
+
+- _**initialList**_ _`IInitialState`_ - Initial list or function returning a list
+
+#### Return
+
+1. **list** - The current list.
+
+2. **actions**
+
+ - **set** - Replaces the current list.
+ - **push** - Adds an item or items to the end of the list.
+ - **updateAt** - 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.
+ - **insertAt** - 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.
+ - **update** - Replaces all items of the list that match the given predicate with the given item.
+ - **updateFirst** - Replaces the first item of the list that matches the given predicate with the
+ given item.
+ - **upsert** - 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.
+ - **sort** - Sorts the list with the given sorting function. If no sorting function is given,
+ the default Array.prototype.sort() sorting is used.
+ - **filter** - Filters the list with the given filter function.
+ - **removeAt** - 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.
+ - **clear** - Deletes all items of the list.
+ - **reset** - Replaces the current list with the initial list given to this hook.
diff --git a/src/useList/__tests__/dom.ts b/src/useList/__tests__/dom.ts
new file mode 100644
index 000000000..f89a0865a
--- /dev/null
+++ b/src/useList/__tests__/dom.ts
@@ -0,0 +1,351 @@
+import { act, renderHook } from '@testing-library/react-hooks/dom';
+import { useList } from '../..';
+
+describe('useList', () => {
+ it('should be defined', () => {
+ expect(useList).toBeDefined();
+ });
+
+ it('should render', () => {
+ const { result } = renderHook(() => useList([]));
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should accept an initial list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ expect(result.current[0]).toEqual([0, 1, 2]);
+ });
+
+ it('should return same actions object on every render', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const actions = result.current[1];
+
+ act(() => {
+ actions.set([3, 4, 5]);
+ });
+
+ expect(result.current[1]).toEqual(actions);
+ });
+
+ describe('set', () => {
+ it('should replace the current list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { set } = result.current[1];
+
+ act(() => {
+ set([3, 4, 5]);
+ });
+
+ expect(result.current[0]).toEqual([3, 4, 5]);
+ });
+
+ it('should replace the current list with empty list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { set } = result.current[1];
+
+ act(() => {
+ set([]);
+ });
+
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it('should functionally replace the current list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { set } = result.current[1];
+
+ act(() => {
+ set((current) => [...current, 3]);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, 3]);
+ });
+ });
+
+ describe('push', () => {
+ it('should push a new item to the list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { push } = result.current[1];
+
+ act(() => {
+ push(3);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, 3]);
+ });
+
+ it('should push multiple items to the list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { push } = result.current[1];
+
+ act(() => {
+ push(3, 4, 5);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, 3, 4, 5]);
+ });
+ });
+
+ describe('updateAt', () => {
+ it('should update item at given position', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { updateAt } = result.current[1];
+
+ act(() => {
+ updateAt(1, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 0, 2]);
+ });
+
+ it('should update item at position that is out of of bounds', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { updateAt } = result.current[1];
+
+ act(() => {
+ updateAt(4, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, undefined, 0]);
+ });
+ });
+
+ describe('insertAt', () => {
+ it('should insert item into given position in the list', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { insertAt } = result.current[1];
+
+ act(() => {
+ insertAt(1, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 0, 1, 2]);
+ });
+
+ it('should insert item into position that is out of bounds', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { insertAt } = result.current[1];
+
+ act(() => {
+ insertAt(4, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, undefined, 0]);
+ });
+ });
+
+ describe('update', () => {
+ it('should update all items that match given predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { update } = result.current[1];
+
+ act(() => {
+ update((iteratedItem: number) => iteratedItem > 0, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 0, 0]);
+ });
+
+ it('should pass update predicate the iterated element and the replacement', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { update } = result.current[1];
+ const predicate = jest.fn((_iteratedItem, _newElement) => false);
+
+ act(() => {
+ update(predicate, 0);
+ });
+
+ expect(numberOfMockFunctionCalls(predicate)).toEqual(3);
+ expect(mockFunctionCallArgument(predicate, 0, 0)).toBe(0);
+ expect(mockFunctionCallArgument(predicate, 0, 1)).toBe(0);
+ });
+
+ it('should not update any items if none match given predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { update } = result.current[1];
+
+ act(() => {
+ update((iteratedItem: number) => iteratedItem > 3, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2]);
+ });
+ });
+
+ describe('updateFirst', () => {
+ it('should update the first item matching the given predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { updateFirst } = result.current[1];
+
+ act(() => {
+ updateFirst((iteratedItem: number) => iteratedItem > 0, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 0, 2]);
+ });
+
+ it('should not update any items if none match given predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { updateFirst } = result.current[1];
+
+ act(() => {
+ updateFirst((iteratedItem: number) => iteratedItem > 3, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2]);
+ });
+ });
+
+ describe('upsert', () => {
+ it('should update the first item matching the given predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { upsert } = result.current[1];
+
+ act(() => {
+ upsert((iteratedItem: number) => iteratedItem > 0, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 0, 2]);
+ });
+
+ it('should push given item to list, if no item matches the predicate', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { upsert } = result.current[1];
+
+ act(() => {
+ upsert((iteratedItem: number) => iteratedItem > 3, 0);
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2, 0]);
+ });
+
+ it('should pass predicate the iterated element and the new element', () => {
+ const { result } = renderHook(() => useList([0, 1, 2]));
+ const { upsert } = result.current[1];
+ const predicate = jest.fn((_iteratedItem, _newElement) => false);
+
+ act(() => {
+ upsert(predicate, 0);
+ });
+
+ expect(numberOfMockFunctionCalls(predicate)).toEqual(3);
+ expect(mockFunctionCallArgument(predicate, 0, 0)).toBe(0);
+ expect(mockFunctionCallArgument(predicate, 0, 1)).toBe(0);
+ });
+ });
+
+ describe('sort', () => {
+ it('should sort list with given sorting function', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { sort } = result.current[1];
+
+ act(() => {
+ sort((a, b) => b - a);
+ });
+
+ expect(result.current[0]).toEqual([2, 1, 0]);
+ });
+
+ it('should use default sorting if sort is called without arguments', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { sort } = result.current[1];
+
+ act(() => {
+ sort();
+ });
+
+ expect(result.current[0]).toEqual([0, 1, 2]);
+ });
+ });
+
+ describe('filter', () => {
+ it('should filter list with given filter function', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { filter } = result.current[1];
+
+ act(() => {
+ filter((a) => a > 0);
+ });
+
+ expect(result.current[0]).toEqual([1, 2]);
+ });
+
+ it('should pass element, its index and iterated list to filter function', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { filter } = result.current[1];
+ const filterFunction = jest.fn((_element, _index, _list) => false);
+
+ act(() => {
+ filter(filterFunction);
+ });
+
+ expect(numberOfMockFunctionCalls(filterFunction)).toEqual(3);
+ expect(mockFunctionCallArgument(filterFunction, 0, 0)).toBe(1);
+ expect(mockFunctionCallArgument(filterFunction, 0, 1)).toBe(0);
+ expect(mockFunctionCallArgument(filterFunction, 0, 2)).toEqual([1, 0, 2]);
+ });
+ });
+
+ describe('removeAt', () => {
+ it('should remove item from given index', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { removeAt } = result.current[1];
+
+ act(() => {
+ removeAt(1);
+ });
+
+ expect(result.current[0]).toEqual([1, 2]);
+ });
+
+ it('should not remove items if given index is out of bounds', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { removeAt } = result.current[1];
+
+ act(() => {
+ removeAt(6);
+ });
+
+ expect(result.current[0]).toEqual([1, 0, 2]);
+ });
+ });
+
+ describe('clear', () => {
+ it('should clear the list', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { clear } = result.current[1];
+
+ act(() => {
+ clear();
+ });
+
+ expect(result.current[0]).toEqual([]);
+ });
+ });
+
+ describe('reset', () => {
+ it('should reset the list to initial value', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ const { reset, set } = result.current[1];
+
+ act(() => {
+ set([1, 1, 1]);
+ reset();
+ });
+
+ expect(result.current[0]).toEqual([1, 0, 2]);
+ });
+ });
+});
+
+function numberOfMockFunctionCalls(mockFunction: jest.Mock) {
+ return mockFunction.mock.calls.length;
+}
+
+function mockFunctionCallArgument(
+ mockFunction: jest.Mock,
+ callIndex: number,
+ argumentIndex: number
+) {
+ return mockFunction.mock.calls[callIndex][argumentIndex];
+}
diff --git a/src/useList/__tests__/ssr.ts b/src/useList/__tests__/ssr.ts
new file mode 100644
index 000000000..f125a630d
--- /dev/null
+++ b/src/useList/__tests__/ssr.ts
@@ -0,0 +1,13 @@
+import { renderHook } from '@testing-library/react-hooks/server';
+import { useList } from '../..';
+
+describe('useList', () => {
+ it('should be defined', () => {
+ expect(useList).toBeDefined();
+ });
+
+ it('should render', () => {
+ const { result } = renderHook(() => useList([1, 0, 2]));
+ expect(result.error).toBeUndefined();
+ });
+});
diff --git a/src/useList/useList.ts b/src/useList/useList.ts
new file mode 100644
index 000000000..b88f96d3f
--- /dev/null
+++ b/src/useList/useList.ts
@@ -0,0 +1,172 @@
+import { SetStateAction, useMemo, useRef } from 'react';
+import { IInitialState, resolveHookState } from '../util/resolveHookState';
+import { useRerender, useSyncedRef } from '..';
+
+export interface 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.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 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: IInitialState): [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].sort(compareFn));
+ },
+
+ filter: (callbackFn: (value: T, index: number, array: T[]) => boolean, thisArg?: never) => {
+ /*
+ We're implementing filter based on the Array.prototype.filter API, thus the API is not going
+ to change, and we can turn off the no-array-callback-reference rule. Also, the filter API
+ requires the thisArg, so we can turn off the no-array-method-this-argument-rule.
+ */
+ // 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];
+}