Skip to content

Commit

Permalink
feat(useStateList): rework useStateList to make it work properly.
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Nov 4, 2019
1 parent 03bdecf commit 242c274
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 126 deletions.
224 changes: 121 additions & 103 deletions src/__tests__/useStateList.test.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,162 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react-hooks';
import useStateList from '../useStateList';

const callNext = hook => {
act(() => {
const { next } = hook.result.current;
next();
describe('useStateList', () => {
it('should be defined', () => {
expect(useStateList).toBeDefined();
});
};

const callPrev = hook => {
act(() => {
const { prev } = hook.result.current;
prev();
});
};
function getHook(list: any[] = ['a', 'b', 'c']) {
return renderHook(({ states }) => useStateList(states), { initialProps: { states: list } });
}

describe('happy flow', () => {
const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
initialProps: {
stateSet: ['a', 'b', 'c'],
},
});
it('should return an object containing `state`, `next` and `prev`', () => {
const res = getHook().result.current;

it('should return the first state on initial render', () => {
const { state } = hook.result.current;
expect(state).toBe('a');
expect(typeof res).toBe('object');
expect(typeof res.state).toBe('string');
expect(typeof res.prev).toBe('function');
expect(typeof res.next).toBe('function');
});

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 on init', () => {
expect(getHook().result.current.state).toBe('a');
});

it('should return the first state again after calling the "next" function "stateSet.length" times', () => {
callNext(hook);
callNext(hook);
describe('next()', () => {
it('should switch states forward and cause re-render', () => {
const hook = getHook();

const { state } = hook.result.current;
expect(state).toBe('a');
});
expect(hook.result.current.state).toBe('a');

it('should return the last state again after calling the "prev" function', () => {
callPrev(hook);
act(() => {
hook.result.current.next();
});
expect(hook.result.current.state).toBe('b');

const { state } = hook.result.current;
expect(state).toBe('c');
});
act(() => {
hook.result.current.next();
});
expect(hook.result.current.state).toBe('c');
});

it('should return the previous state after calling the "prev" function', () => {
callPrev(hook);
it('should on overflow should switch to first element (should be cycled)', () => {
const hook = getHook();

const { state } = hook.result.current;
expect(state).toBe('b');
});
});
expect(hook.result.current.state).toBe('a');

describe('with empty state set', () => {
const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
initialProps: {
stateSet: [],
},
act(() => {
hook.result.current.next();
hook.result.current.next();
hook.result.current.next();
});
expect(hook.result.current.state).toBe('a');
});
});

it('should return undefined on initial render', () => {
const { state } = hook.result.current;
expect(state).toBe(undefined);
});
describe('prev()', () => {
it('should on overflow should switch to last element (should be cycled)', () => {
const hook = getHook();

it('should always return undefined (calling next)', () => {
callNext(hook);
expect(hook.result.current.state).toBe('a');

const { state } = hook.result.current;
expect(state).toBe(undefined);
});
act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('c');
});

it('should always return undefined (calling prev)', () => {
callPrev(hook);
it('should switch states backward and cause re-render', () => {
const hook = getHook();

const { state } = hook.result.current;
expect(state).toBe(undefined);
});
});
expect(hook.result.current.state).toBe('a');

describe('with a single state set', () => {
const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
initialProps: {
stateSet: ['a'],
},
});
act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('c');

act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('b');

it('should return "a" on initial render', () => {
const { state } = hook.result.current;
expect(state).toBe('a');
act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('a');
});
});

it('should always return "a" (calling next)', () => {
callNext(hook);
describe('with empty states list', () => {
it('should have `undefined` state', () => {
expect(getHook([]).result.current.state).toBe(undefined);
});

it('should do nothing on next() call', () => {
const hook = getHook([]);
act(() => {
hook.result.current.next();
});

const { state } = hook.result.current;
expect(state).toBe('a');
});
expect(hook.result.current.state).toBe(undefined);
});

it('should always return "a" (calling prev)', () => {
callPrev(hook);
it('should do nothing on prev() call', () => {
const hook = getHook([]);
act(() => {
hook.result.current.prev();
});

const { state } = hook.result.current;
expect(state).toBe('a');
expect(hook.result.current.state).toBe(undefined);
});
});
});

describe('with stateSet updates', () => {
const hook = renderHook(({ stateSet }) => useStateList(stateSet), {
initialProps: {
stateSet: ['a', 'c', 'b', 'f', 'g'],
},
});
describe('on state list shrink', () => {
it('should set last element as state if index was beyond new last element', () => {
const hook = getHook();
act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('c');

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
hook.rerender({ states: ['a', 'b'] });

// Update the state set with less elements
hook.rerender({
stateSet: ['a', 'c'],
expect(hook.result.current.state).toBe('b');
});

const { state } = hook.result.current;
expect(state).toBe('c');
it('should so nothing if current index within new range', () => {
const hook = getHook();
act(() => {
hook.result.current.prev();
});
expect(hook.result.current.state).toBe('c');

hook.rerender({ states: ['a', 'b', 'c', 'd'] });

expect(hook.result.current.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'],
describe('ou unmounted component', () => {
it('next() should not do anything', () => {
const hook = getHook();
const { next } = hook.result.current;

hook.unmount();
act(() => next());

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

const { state } = hook.result.current;
expect(state).toBe('f');
it('prev() should not do anything', () => {
const hook = getHook();
const { prev } = hook.result.current;

hook.unmount();
act(() => prev());

expect(hook.result.current.state).toBe('a');
});
});
});
59 changes: 36 additions & 23 deletions src/useStateList.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,46 @@
import { useState, useCallback } from 'react';

import { useCallback, 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 } {
const [currentIndex, setCurrentIndex] = useState(0);
const isMounted = useMountedState();
const update = useUpdate();
const index = useRef(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.
// If new state list is shorter that before - switch to the last element
useUpdateEffect(() => {
if (!stateSet[currentIndex]) {
setCurrentIndex(stateSet.length - 1);
if (stateSet.length <= index.current) {
index.current = stateSet.length - 1;
update();
}
}, [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]);
}, [stateSet.length]);

return {
state: stateSet[currentIndex],
next,
prev,
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;
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;
update();
}
}, [stateSet, index]),
};
}

0 comments on commit 242c274

Please sign in to comment.