diff --git a/README.md b/README.md
index 8e36339e0d..548f31b86f 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,7 @@
- [`usePrevious`](./docs/usePrevious.md) — returns the previous state or props.
- [`useObservable`](./docs/useObservable.md) — tracks latest value of an `Observable`.
- [`useSetState`](./docs/useSetState.md) — creates `setState` method which works like `this.setState`. [![][img-demo]](https://codesandbox.io/s/n75zqn1xp0)
+ - [`useStateList`](./docs/useStateList.md) — circularly iterates over an array.
- [`useToggle` and `useBoolean`](./docs/useToggle.md) — tracks state of a boolean.
- [`useCounter` and `useNumber`](./docs/useCounter.md) — tracks state of a number. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usecounter--demo)
- [`useList`](./docs/useList.md) — tracks state of an array.
diff --git a/docs/useStateList.md b/docs/useStateList.md
new file mode 100644
index 0000000000..b9c91885a7
--- /dev/null
+++ b/docs/useStateList.md
@@ -0,0 +1,25 @@
+# `useStateList`
+
+React state hook that circularly iterates over an array.
+
+## Usage
+
+```jsx
+import { useStateList } from 'react-use';
+
+const stateSet = ['first', 'second', 'third', 'fourth', 'fifth'];
+
+const Demo = () => {
+ const {state, prev, next} = useStateList(stateSet);
+
+ return (
+
+
{state}
+
+
+
+ );
+};
+```
+
+> If the `stateSet` is changed by a shorter one the hook will select the last element of it.
diff --git a/src/__stories__/useStateList.story.tsx b/src/__stories__/useStateList.story.tsx
new file mode 100644
index 0000000000..219f753139
--- /dev/null
+++ b/src/__stories__/useStateList.story.tsx
@@ -0,0 +1,22 @@
+import { storiesOf } from '@storybook/react';
+import * as React from 'react';
+import { useStateList } from '..';
+import ShowDocs from './util/ShowDocs';
+
+const stateSet = ['first', 'second', 'third', 'fourth', 'fifth'];
+
+const Demo = () => {
+ const { state, prev, next } = useStateList(stateSet);
+
+ return (
+
+
{state}
+
+
+
+ );
+};
+
+storiesOf('State|useStateList', module)
+ .add('Docs', () => )
+ .add('Demo', () => );
diff --git a/src/__tests__/useStateList.test.ts b/src/__tests__/useStateList.test.ts
new file mode 100644
index 0000000000..b65d324173
--- /dev/null
+++ b/src/__tests__/useStateList.test.ts
@@ -0,0 +1,144 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import useStateList from '../useStateList';
+
+const callNext = hook => {
+ act(() => {
+ const { next } = hook.result.current;
+ next();
+ });
+};
+
+const callPrev = hook => {
+ act(() => {
+ const { prev } = hook.result.current;
+ prev();
+ });
+};
+
+describe('happy flow', () => {
+ const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
+ initialProps: {
+ stateSet: ['a', 'b', 'c'],
+ },
+ });
+
+ it('should return the first state on initial render', () => {
+ const { state } = hook.result.current;
+ expect(state).toBe('a');
+ });
+
+ it('should return the second state after calling the "next" function', () => {
+ callNext(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('b');
+ });
+
+ it('should return the first state again after calling the "next" function "stateSet.length" times', () => {
+ callNext(hook);
+ callNext(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('a');
+ });
+
+ it('should return the last state again after calling the "prev" function', () => {
+ callPrev(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('c');
+ });
+
+ it('should return the previous state after calling the "prev" function', () => {
+ callPrev(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('b');
+ });
+});
+
+describe('with empty state set', () => {
+ const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
+ initialProps: {
+ stateSet: [],
+ },
+ });
+
+ it('should return undefined on initial render', () => {
+ const { state } = hook.result.current;
+ expect(state).toBe(undefined);
+ });
+
+ it('should always return undefined (calling next)', () => {
+ callNext(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe(undefined);
+ });
+
+ it('should always return undefined (calling prev)', () => {
+ callPrev(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe(undefined);
+ });
+});
+
+describe('with a single state set', () => {
+ const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
+ initialProps: {
+ stateSet: ['a'],
+ },
+ });
+
+ it('should return "a" on initial render', () => {
+ const { state } = hook.result.current;
+ expect(state).toBe('a');
+ });
+
+ it('should always return "a" (calling next)', () => {
+ callNext(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('a');
+ });
+
+ it('should always return "a" (calling prev)', () => {
+ callPrev(hook);
+
+ const { state } = hook.result.current;
+ expect(state).toBe('a');
+ });
+});
+
+describe('with stateSet updates', () => {
+ const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
+ initialProps: {
+ stateSet: ['a', 'c', 'b', 'f', 'g'],
+ },
+ });
+
+ it('should return the last element after updating with a shorter state set', () => {
+ // Go to the 4th state
+ callNext(hook); // c
+ callNext(hook); // b
+ callNext(hook); // f
+
+ // Update the state set with less elements
+ hook.rerender({
+ stateSet: ['a', 'c'],
+ });
+
+ const { state } = hook.result.current;
+ expect(state).toBe('c');
+ });
+
+ it('should return the element in the same position after updating with a larger state set', () => {
+ hook.rerender({
+ stateSet: ['a', 'f', 'l'],
+ });
+
+ const { state } = hook.result.current;
+ expect(state).toBe('f');
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index 644f587e73..99f43633a5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,6 +70,7 @@ export { default as useSpeech } from './useSpeech';
// not exported because of peer dependency
// export { default as useSpring } from './useSpring';
export { default as useStartTyping } from './useStartTyping';
+export { default as useStateList } from './useStateList';
export { default as useThrottle } from './useThrottle';
export { default as useThrottleFn } from './useThrottleFn';
export { default as useTimeout } from './useTimeout';
diff --git a/src/useStateList.ts b/src/useStateList.ts
new file mode 100644
index 0000000000..7f4f83b089
--- /dev/null
+++ b/src/useStateList.ts
@@ -0,0 +1,33 @@
+import { useState, useCallback } from 'react';
+
+import useUpdateEffect from './useUpdateEffect';
+
+export default function useStateList(stateSet: T[] = []): { state: T; next: () => void; prev: () => void } {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ // In case we receive a different state set, check if the current index still exists and
+ // reset it to the last if it don't.
+ useUpdateEffect(() => {
+ if (!stateSet[currentIndex]) {
+ setCurrentIndex(stateSet.length - 1);
+ }
+ }, [stateSet]);
+
+ const next = useCallback(() => {
+ const nextStateIndex = stateSet.length === currentIndex + 1 ? 0 : currentIndex + 1;
+
+ setCurrentIndex(nextStateIndex);
+ }, [stateSet, currentIndex]);
+
+ const prev = useCallback(() => {
+ const prevStateIndex = currentIndex === 0 ? stateSet.length - 1 : currentIndex - 1;
+
+ setCurrentIndex(prevStateIndex);
+ }, [stateSet, currentIndex]);
+
+ return {
+ state: stateSet[currentIndex],
+ next,
+ prev,
+ };
+}