Skip to content

Commit

Permalink
feat(useStateList): implemented currentIndex, setState, `setState…
Browse files Browse the repository at this point in the history
…At` methods as requested in #634;

Reworked a bit implementation of `next` and `prev` to make it reuse the `setStateAt` method;
  • Loading branch information
xobotyi committed Nov 7, 2019
1 parent 27a5e5f commit 43cb6aa
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 38 deletions.
35 changes: 31 additions & 4 deletions docs/useStateList.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,52 @@
# `useStateList`

React state hook that circularly iterates over an array.
Provides handles for circular iteration over states list.
Supports forward and backward iterations and arbitrary position set.

## Usage

```jsx
import { useStateList } from 'react-use';
import { useRef } from 'react';

const stateSet = ['first', 'second', 'third', 'fourth', 'fifth'];

const Demo = () => {
const {state, prev, next} = useStateList(stateSet);
const { state, prev, next, setStateAt, setState, currentIndex } = useStateList(stateSet);
const indexInput = useRef<HTMLInputElement>(null);
const stateInput = useRef<HTMLInputElement>(null);

return (
<div>
<pre>{state}</pre>
<pre>
{state} [index: {currentIndex}]
</pre>
<button onClick={() => prev()}>prev</button>
<br />
<button onClick={() => next()}>next</button>
<br />
<input type="text" ref={indexInput} style={{ width: 120 }} />
<button onClick={() => setStateAt((indexInput.current!.value as unknown) as number)}>set state by index</button>
<br />
<input type="text" ref={stateInput} style={{ width: 120 }} />
<button onClick={() => setState(stateInput.current!.value)}> set state by value</button>
</div>
);
};
```

> If the `stateSet` is changed by a shorter one the hook will select the last element of it.
## Reference

```ts
const { state, currentIndex, prev, next, setStateAt, setState } = useStateList<T>(stateSet: T[] = []);
```

If `stateSet` changed, became shorter than before and `currentIndex` left in shrinked gap - the last element of list will be taken as current.

- **`state`**_`: T`_ &mdash; current state value;
- **`currentIndex`**_`: number`_ &mdash; current state index;
- **`prev()`**_`: void`_ &mdash; switches state to the previous one. If first element selected it will switch to the last one;
- **`nexct()`**_`: void`_ &mdash; switches state to the next one. If last element selected it will switch to the first one;
- **`setStateAt(newIndex: number)`**_`: void`_ &mdash; set the arbitrary state by index. Indexes are looped, and can be negative.
_4ex:_ if list contains 5 elements, attempt to set index 9 will bring use to the 5th element, in case of negative index it will start counting from the right, so -17 will bring us to the 4th element.
- **`setState(state: T)`**_`: void`_ &mdash; set the arbitrary state value that exists in `stateSet`. _In case new state does not exists in `stateSet` an Error will be thrown._
16 changes: 14 additions & 2 deletions src/__stories__/useStateList.story.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useRef } 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);
const { state, prev, next, setStateAt, setState, currentIndex } = useStateList(stateSet);
const indexInput = useRef<HTMLInputElement>(null);
const stateInput = useRef<HTMLInputElement>(null);

return (
<div>
<pre>{state}</pre>
<pre>
{state} [index: {currentIndex}]
</pre>
<button onClick={() => prev()}>prev</button>
<br />
<button onClick={() => next()}>next</button>
<br />
<input type="text" ref={indexInput} style={{ width: 120 }} />
<button onClick={() => setStateAt((indexInput.current!.value as unknown) as number)}>set state by index</button>
<br />
<input type="text" ref={stateInput} style={{ width: 120 }} />
<button onClick={() => setState(stateInput.current!.value)}> set state by value</button>
</div>
);
};
Expand Down
87 changes: 81 additions & 6 deletions src/__tests__/useStateList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,91 @@ describe('useStateList', () => {
it('should return an object containing `state`, `next` and `prev`', () => {
const res = getHook().result.current;

expect(typeof res).toBe('object');
expect(typeof res.state).toBe('string');
expect(typeof res.prev).toBe('function');
expect(typeof res.next).toBe('function');
expect(res).toStrictEqual({
state: expect.any(String),
currentIndex: expect.any(Number),
prev: expect.any(Function),
next: expect.any(Function),
setStateAt: expect.any(Function),
setState: expect.any(Function),
});
});

it('should return the first state on init', () => {
expect(getHook().result.current.state).toBe('a');
});

describe('setState()', () => {
it('should set state value if it exists in states list', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');

act(() => hook.result.current.setState('c'));

expect(hook.result.current.state).toBe('c');
});

it('should throw if required state not exists', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');

expect(() => hook.result.current.setState('d')).toThrow(
`State 'd' is not a valid state (does not exist in state list)`
);
});

it('should do nothing on unmounted component', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');
hook.unmount();

expect(() => hook.result.current.setState('c')).not.toThrow(Error);
expect(hook.result.current.state).toBe('a');
});
});

describe('setStateAt()', () => {
it('should set state by it`s index in states list', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');

act(() => hook.result.current.setStateAt(2));
expect(hook.result.current.state).toBe('c');
act(() => hook.result.current.setStateAt(1));
expect(hook.result.current.state).toBe('b');
});

it('should cyclically travel through the right border', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');

act(() => hook.result.current.setStateAt(5));
expect(hook.result.current.state).toBe('c');
act(() => hook.result.current.setStateAt(9));
expect(hook.result.current.state).toBe('a');
act(() => hook.result.current.setStateAt(10));
expect(hook.result.current.state).toBe('b');
});

it('should cyclically travel through the left border', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');

act(() => hook.result.current.setStateAt(-1));
expect(hook.result.current.state).toBe('c');
act(() => hook.result.current.setStateAt(-2));
expect(hook.result.current.state).toBe('b');
act(() => hook.result.current.setStateAt(-17));
expect(hook.result.current.state).toBe('b');
});
});

describe('next()', () => {
it('should switch states forward and cause re-render', () => {
const hook = getHook();
Expand All @@ -40,7 +115,7 @@ describe('useStateList', () => {
expect(hook.result.current.state).toBe('c');
});

it('should on overflow should switch to first element (should be cycled)', () => {
it('on overflow should switch to first element (should be cycled)', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');
Expand All @@ -55,7 +130,7 @@ describe('useStateList', () => {
});

describe('prev()', () => {
it('should on overflow should switch to last element (should be cycled)', () => {
it('on overflow should switch to last element (should be cycled)', () => {
const hook = getHook();

expect(hook.result.current.state).toBe('a');
Expand Down
76 changes: 50 additions & 26 deletions src/useStateList.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { useCallback, useRef } from 'react';
import { useMemo, useRef } from 'react';
import useMountedState from './useMountedState';
import useUpdate from './useUpdate';
import useUpdateEffect from './useUpdateEffect';

export default function useStateList<T>(stateSet: T[] = []): { state: T; next: () => void; prev: () => void } {
export interface UseStateListReturn<T> {
state: T;
currentIndex: number;
setStateAt: (newIndex: number) => void;
setState: (state: T) => void;
next: () => void;
prev: () => void;
}

export default function useStateList<T>(stateSet: T[] = []): UseStateListReturn<T> {
const isMounted = useMountedState();
const update = useUpdate();
const index = useRef(0);
Expand All @@ -16,31 +25,46 @@ export default function useStateList<T>(stateSet: T[] = []): { state: T; next: (
}
}, [stateSet.length]);

return {
state: stateSet[index.current],
next: useCallback(() => {
// do nothing on unmounted component
if (!isMounted()) {
return;
}

// act only if stateSet has element within
if (stateSet.length) {
index.current = (index.current + 1) % stateSet.length;
const actions = useMemo(
() => ({
next: () => actions.setStateAt(index.current + 1),
prev: () => actions.setStateAt(index.current - 1),
setStateAt: (newIndex: number) => {
// do nothing on unmounted component
if (!isMounted()) return;

// do nothing on empty states list
if (!stateSet.length) return;

// in case new index is equal current - do nothing
if (newIndex === index.current) return;

// it gives the ability to travel through the left and right borders.
// 4ex: if list contains 5 elements, attempt to set index 9 will bring use to 5th element
// in case of negative index it will start counting from the right, so -17 will bring us to 4th element
index.current = newIndex >= 0 ? newIndex % stateSet.length : stateSet.length + (newIndex % stateSet.length);
update();
}
}, [stateSet, index]),
prev: useCallback(() => {
// do nothing on unmounted component
if (!isMounted()) {
return;
}

// act only if stateSet has element within
if (stateSet.length) {
index.current = index.current - 1 < 0 ? stateSet.length - 1 : index.current - 1;
},
setState: (state: T) => {
// do nothing on unmounted component
if (!isMounted()) return;

const newIndex = stateSet.length ? stateSet.indexOf(state) : -1;

if (newIndex === -1) {
throw new Error(`State '${state}' is not a valid state (does not exist in state list)`);
}

index.current = newIndex;
update();
}
}, [stateSet, index]),
},
}),
[stateSet]
);

return {
state: stateSet[index.current],
currentIndex: index.current,
...actions,
};
}

0 comments on commit 43cb6aa

Please sign in to comment.