Skip to content

Commit

Permalink
feat: implement useRafState hook
Browse files Browse the repository at this point in the history
Like `React.useState`, but state is only updated within animation frame.
  • Loading branch information
xobotyi authored and JoeDuncko committed Mar 13, 2022
1 parent 3ccb968 commit 6cd44ec
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ Coming from `react-use`? Check out our
Returns the value passed to the hook on previous render.
- [**`usePreviousDistinct`**](https://react-hookz.github.io/web/?path=/docs/state-usepreviousdistinct--example)
Returns the most recent distinct value passed to the hook on previous render.
- [**`useRafState`**](https://react-hookz.github.io/web/?path=/docs/state-userafstate--example)
Like `React.useState`, but state is only updated within animation frame.
- [**`useSafeState`**](https://react-hookz.github.io/web/?path=/docs/state-usesafestate--page)
Like `useState`, but its state setter is guarded against sets on unmounted component.
- [**`useSet`**](https://react-hookz.github.io/web/?path=/docs/state-useset--example) — Tracks the
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { useMap } from './useMap/useMap';
export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
export { usePreviousDistinct, Predicate } from './usePreviousDistinct/usePreviousDistinct';
export { useRafState } from './useRafState/useRafState';
export { useSafeState } from './useSafeState/useSafeState';
export { useSet } from './useSet/useSet';
export { useToggle } from './useToggle/useToggle';
Expand Down
30 changes: 30 additions & 0 deletions src/useRafState/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';
import { useMountEffect, useRafState } from '../..';

export const Example: React.FC = () => {
const [state, setState] = useRafState({ x: 0, y: 0 });

useMountEffect(() => {
const onMouseMove = (event: MouseEvent) => {
setState({ x: event.clientX, y: event.clientY });
};
const onTouchMove = (event: TouchEvent) => {
setState({ x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY });
};

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('touchmove', onTouchMove);

return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('touchmove', onTouchMove);
};
});

return (
<div>
Below state will be updated on mouse/cursor move within animation frame
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
);
};
32 changes: 32 additions & 0 deletions src/useRafState/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';
import { ImportPath } from '../../storybookUtil/ImportPath';

<Meta title="State/useRafState" component={Example} />

# useRafState

Like `React.useState`, but state is only updated within animation frame.

- Auto-cancel animation frame on component unmount.
- SSR-friendly.

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useRafState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
export function useRafState<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>
];
```

#### Importing

<ImportPath />
67 changes: 67 additions & 0 deletions src/useRafState/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { useRafState } from '../..';

describe('useRafState', () => {
const raf = global.requestAnimationFrame;
const caf = global.cancelAnimationFrame;

beforeAll(() => {
jest.useFakeTimers();

global.requestAnimationFrame = (cb) => setTimeout(cb);
global.cancelAnimationFrame = (cb) => clearTimeout(cb);
});

afterEach(() => {
jest.clearAllTimers();
});

afterAll(() => {
jest.useRealTimers();

global.requestAnimationFrame = raf;
global.cancelAnimationFrame = caf;
});

it('should be defined', () => {
expect(useRafState).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useRafState());
expect(result.error).toBeUndefined();
});

it('should not update state unless animation frame', () => {
const { result } = renderHook(() => useRafState<number>());

act(() => {
result.current[1](1);
result.current[1](2);
result.current[1](3);
});

expect(result.current[0]).toBeUndefined();

act(() => {
jest.advanceTimersToNextTimer();
});

expect(result.current[0]).toBe(3);
expect(result.all.length).toBe(2);
});

it('should cancel animation frame on unmount', () => {
const { result, unmount } = renderHook(() => useRafState<number>());

act(() => {
result.current[1](1);
result.current[1](2);
result.current[1](3);
});

unmount();

expect(result.current[0]).toBeUndefined();
});
});
13 changes: 13 additions & 0 deletions src/useRafState/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useRafState } from '../..';

describe('useRafState', () => {
it('should be defined', () => {
expect(useRafState).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useRafState());
expect(result.error).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions src/useRafState/useRafState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Dispatch, SetStateAction } from 'react';
import { useRafCallback, useSafeState, useUnmountEffect } from '../..';

export function useRafState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
export function useRafState<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>
];

/**
* Like `React.useState`, but state is only updated within animation frame.
*/
export function useRafState<S>(
initialState?: S | (() => S)
): [S | undefined, Dispatch<SetStateAction<S>>] {
const [state, innerSetState] = useSafeState(initialState);

const [setState, cancelRaf] = useRafCallback(innerSetState);

useUnmountEffect(cancelRaf);

return [state, setState as Dispatch<SetStateAction<S>>];
}

0 comments on commit 6cd44ec

Please sign in to comment.