Skip to content

Commit

Permalink
feat: useStateHistory (#709)
Browse files Browse the repository at this point in the history
* feat: add useStateHistory hook;
* fix: createBreakpoint lint fix (author skipped hooks);
  • Loading branch information
xobotyi authored Nov 28, 2019
1 parent 64b74eb commit 0a66359
Show file tree
Hide file tree
Showing 7 changed files with 596 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
- [`useSet`](./docs/useSet.md) — tracks state of a Set. [![][img-demo]](https://codesandbox.io/s/bold-shtern-6jlgw)
- [`useQueue`](./docs/useQueue.md) — implements simple queue.
- [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo)
- [`useStateWithHistory`](./docs/useStateWithHistory.md) — stores previous state values and provides handles to travel through them. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatewithhistory--demo)
- [`useMultiStateValidator`](./docs/useMultiStateValidator.md) — alike the `useStateValidator`, but tracks multiple states at a time. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo)
- [`useMediatedState`](./docs/useMediatedState.md) — like the regular `useState` but with mediation by custom function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemediatedstate--demo)
- [`useFirstMountState`](./docs/useFirstMountState.md) — check if current render is first. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usefirstmountstate--demo)
Expand Down
33 changes: 33 additions & 0 deletions docs/useStateWithHistory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# `useStateHistory`

Stores defined amount of previous state values and provides handles to travel through them.

## Usage

## Reference

```typescript
const [state, setState, stateHistory] = useStateWithHistory<S = undefined>(
initialState?: S | (()=>S),
historyCapacity?: number = 10,
initialHistory?: S
);
```

- **`state`**, **`setState`** and **`initialState`** are exactly the same with native React's `useState` hook;
- **`historyCapacity`** - amount of history entries that hold by storage;
- **`initialHistory`** - if defined it will be used as initial history value, otherwise history will equals `[ initialState ]`.
Initial state will not be pushed to initial history.
If entries amount is greater than `historyCapacity` parameter it wont be modified on init but will be trimmed on next `setState`;
- **`stateHistory`** - an object containing history state:
- **`history`**_`: S[]`_ - an array holding history entries. _I will have the same ref all the time so pe careful with that one!_;
- **`position`**_`: number`_ - current position _index_ in history;
- **`capacity`**_`: number = 10`_ - maximum amount of history entries;
- **`back`**_`: (amount?: number) => void`_ - go back in state history, it will cause `setState` invoke and component re-render.
If first element of history reached, the call will have no effect;
- **`forward`**_`: (amount?: number) => void`_ - go forward in state history, it will cause `setState` invoke and component re-render.
If last element of history reached, the call will have no effect;
- **`go`**_`: (position: number) => void`_ - go to arbitrary position in history.
In case `position` is non-negative ot will count elements from beginning.
Negative `position` will cause elements counting from the end, so `go(-2)` equals `go(history.length - 1)`;

2 changes: 1 addition & 1 deletion src/__stories__/createBreakpoint.story.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { number, withKnobs } from '@storybook/addon-knobs';
import { withKnobs } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import React from 'react';
import { createBreakpoint } from '..';
Expand Down
89 changes: 89 additions & 0 deletions src/__stories__/useStateWithHistory.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useCallback, useRef } from 'react';
import { useCounter, useStateWithHistory } from '..';
import ShowDocs from './util/ShowDocs';

const Demo = () => {
const [state, setState, history] = useStateWithHistory('', 10, ['hello', 'world']);
const inputRef = useRef<HTMLInputElement | null>(null);

const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1);

const handleFormSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
state !== inputRef.current!.value && setState(inputRef.current!.value);
},
[state]
);

const handleBackClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (e.currentTarget.disabled) {
return;
}

history.back(stepSize);
},
[history, stepSize]
);

const handleForwardClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (e.currentTarget.disabled) {
return;
}

history.forward(stepSize);
},
[history, stepSize]
);

const handleStepSizeChange = useCallback(
(e: React.FormEvent<HTMLInputElement>) => {
setStepSize((e.currentTarget.value as any) * 1);
},
[stepSize]
);

return (
<div>
<div>
<form onSubmit={handleFormSubmit} style={{ display: 'inline-block' }}>
<input type="text" ref={inputRef} />
<button>Submit new state</button>
</form>
</div>

<div style={{ marginTop: 8 }}>
Current state: <span>{state}</span>
</div>
<div style={{ marginTop: 8 }}>
<button onClick={handleBackClick} disabled={!history.position}>
&lt; Back
</button>
<button onClick={handleForwardClick} disabled={history.position >= history.history.length - 1}>
Forward &gt;
</button>
&nbsp; Step size:&nbsp;
<input type="number" value={stepSize} min={1} max={3} onChange={handleStepSizeChange} />
</div>

<div style={{ marginTop: 8 }}>
<div>Current history</div>
<div
dangerouslySetInnerHTML={{
__html: JSON.stringify(history.history, null, 2)
.replace(/\n/g, '<br/>')
.replace(/ /g, '&nbsp;'),
}}
/>
</div>
</div>
);
};

storiesOf('State|useStateWithHistory', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useStateWithHistory.md')} />)
.add('Demo', () => <Demo />);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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 { useStateWithHistory } from './useStateWithHistory';
export { default as useStateList } from './useStateList';
export { default as useThrottle } from './useThrottle';
export { default as useThrottleFn } from './useThrottleFn';
Expand Down
131 changes: 131 additions & 0 deletions src/useStateWithHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Dispatch, useCallback, useMemo, useRef, useState } from 'react';
import { useFirstMountState } from './useFirstMountState';
import { InitialHookState, ResolvableHookState, resolveHookState } from './util/resolveHookState';

interface HistoryState<S> {
history: S[];
position: number;
capacity: number;
back: (amount?: number) => void;
forward: (amount?: number) => void;
go: (position: number) => void;
}

export type UseStateHistoryReturn<S> = [S, Dispatch<ResolvableHookState<S>>, HistoryState<S>];

export function useStateWithHistory<S, I extends S>(
initialState: InitialHookState<S>,
capacity?: number,
initialHistory?: I[]
): UseStateHistoryReturn<S>;
export function useStateWithHistory<S = undefined>(): UseStateHistoryReturn<S | undefined>;

export function useStateWithHistory<S, I extends S>(
initialState?: InitialHookState<S>,
capacity: number = 10,
initialHistory?: I[]
): UseStateHistoryReturn<S> {
if (capacity < 1) {
throw new Error(`Capacity has to be greater than 1, got '${capacity}'`);
}

const isFirstMount = useFirstMountState();
const [state, innerSetState] = useState<S>(initialState as S);
const history = useRef<S[]>((initialHistory ?? []) as S[]);
const historyPosition = useRef(0);

// do the states manipulation only on first mount, no sense to load re-renders with useless calculations
if (isFirstMount) {
if (history.current.length) {
// if last element of history !== initial - push initial to history
if (history.current[history.current.length - 1] !== initialState) {
history.current.push(initialState as I);
}

// if initial history bigger that capacity - crop the first elements out
if (history.current.length > capacity) {
history.current = history.current.slice(history.current.length - capacity);
}
} else {
// initiate the history with initial state
history.current.push(initialState as I);
}

historyPosition.current = history.current.length && history.current.length - 1;
}

const setState = useCallback(
(newState: ResolvableHookState<S>): void => {
innerSetState(currentState => {
newState = resolveHookState(newState);

// is state has changed
if (newState !== currentState) {
// if current position is not the last - pop element to the right
if (historyPosition.current < history.current.length - 1) {
history.current = history.current.slice(0, historyPosition.current + 1);
}

historyPosition.current = history.current.push(newState as I) - 1;

// if capacity is reached - shift first elements
if (history.current.length > capacity) {
history.current = history.current.slice(history.current.length - capacity);
}
}

return newState;
});
},
[state, capacity]
) as Dispatch<ResolvableHookState<S>>;

const historyState = useMemo(
() => ({
history: history.current,
position: historyPosition.current,
capacity,
back: (amount: number = 1) => {
// don't do anything if we already at the left border
if (!historyPosition.current) {
return;
}

innerSetState(() => {
historyPosition.current -= Math.min(amount, historyPosition.current);

return history.current[historyPosition.current];
});
},
forward: (amount: number = 1) => {
// don't do anything if we already at the right border
if (historyPosition.current === history.current.length - 1) {
return;
}

innerSetState(() => {
historyPosition.current = Math.min(historyPosition.current + amount, history.current.length - 1);

return history.current[historyPosition.current];
});
},
go: (position: number) => {
if (position === historyPosition.current) {
return;
}

innerSetState(() => {
historyPosition.current =
position < 0
? Math.max(history.current.length + position, 0)
: Math.min(history.current.length - 1, position);

return history.current[historyPosition.current];
});
},
}),
[state]
);

return [state, setState, historyState];
}
Loading

0 comments on commit 0a66359

Please sign in to comment.