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, + }; +}