Skip to content

Commit

Permalink
feat: add useRafLoop hook
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich authored Aug 3, 2019
2 parents 8cb81c6 + da3628e commit be7d7c3
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
- [`useFavicon`](./docs/useFavicon.md) — sets favicon of the page.
- [`useLocalStorage`](./docs/useLocalStorage.md) — manages a value in `localStorage`.
- [`useLockBodyScroll`](./docs/useLockBodyScroll.md) — lock scrolling of the body element.
- [`useRafLoop`](./docs/useRafLoop.md) — calls given function inside the RAF loop.
- [`useSessionStorage`](./docs/useSessionStorage.md) — manages a value in `sessionStorage`.
- [`useThrottle` and `useThrottleFn`](./docs/useThrottle.md) — throttles a function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/side-effects-usethrottle--demo)
- [`useTitle`](./docs/useTitle.md) — sets title of the page.
Expand Down
35 changes: 35 additions & 0 deletions docs/useRafLoop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# `useRafLoop`

React hook that calls given function inside the RAF loop without re-rendering parent component if not needed. Loop stops automatically on component unmount.
Provides controls to stop and start loop manually.

## Usage

```jsx
import * as React from 'react';
import { useRafLoop } from 'react-use';

const Demo = () => {
const [ticks, setTicks] = React.useState(0);

const [loopStop, isActive, loopStart] = useRafLoop(() => {
setTicks(ticks + 1);
}, [ticks]);

return (
<div>
<div>RAF triggered: {ticks} (times)</div>
<br />
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
</div>
);
};
```

## Reference

```ts
const [stopLoop, isActive, startLoop] = useRafLoop(callback: CallableFunction, deps?: DependencyList);
```
* `callback` &mdash; function to call each RAF tick

24 changes: 24 additions & 0 deletions src/__stories__/useRafLoop.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import ShowDocs from './util/ShowDocs';
import { useRafLoop } from '..';

const Demo = () => {
const [ticks, setTicks] = React.useState(0);

const [loopStop, isActive, loopStart] = useRafLoop(() => {
setTicks(ticks + 1);
});

return (
<div>
<div>RAF triggered: {ticks} (times)</div>
<br />
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
</div>
);
};

storiesOf('Side effects|useRafLoop', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useRafLoop.md')} />)
.add('Demo', () => <Demo />);
113 changes: 113 additions & 0 deletions src/__tests__/useRafLoop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { act, renderHook } from 'react-hooks-testing-library';
import useRafLoop from '../useRafLoop';

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

it('should call a callback constantly inside the raf loop', done => {
let calls = 0;
const spy = () => calls++;
renderHook(() => useRafLoop(spy), { initialProps: false });

expect(calls).toEqual(0);

setTimeout(() => {
expect(calls).toBeGreaterThanOrEqual(5);
expect(calls).toBeLessThan(10);

done();
}, 100);
});

it('should return stop function, start function and loop state', () => {
const hook = renderHook(() => useRafLoop(() => false), { initialProps: false });

expect(typeof hook.result.current[0]).toEqual('function');
expect(typeof hook.result.current[1]).toEqual('boolean');
expect(typeof hook.result.current[2]).toEqual('function');
});

it('first element call should stop the loop', done => {
let calls = 0;
const spy = () => calls++;
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

// stop the loop
act(() => {
hook.result.current[0]();
});

setTimeout(() => {
expect(calls).toEqual(0);

done();
}, 100);
});

it('second element should represent loop state', done => {
let calls = 0;
const spy = () => calls++;
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

expect(hook.result.current[1]).toBe(true);

// stop the loop
act(() => {
hook.result.current[0]();
});

expect(hook.result.current[1]).toBe(false);
setTimeout(() => {
expect(calls).toEqual(0);

done();
}, 100);
});

it('third element call should restart loop', done => {
let calls = 0;
const spy = () => calls++;
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

expect(hook.result.current[1]).toBe(true);

// stop the loop
act(() => {
hook.result.current[0]();
});

setTimeout(() => {
expect(hook.result.current[1]).toBe(false);
expect(calls).toEqual(0);

// start the loop
act(() => {
hook.result.current[2]();
});

setTimeout(() => {
expect(hook.result.current[1]).toBe(true);
expect(calls).toBeGreaterThanOrEqual(5);
expect(calls).toBeLessThan(10);

done();
}, 100);
}, 100);
});

it('loop should stop itself on unmount', done => {
let calls = 0;
const spy = () => calls++;
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

hook.unmount();

setTimeout(() => {
expect(calls).toEqual(0);

done();
}, 100);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export { default as usePermission } from './usePermission';
export { default as usePrevious } from './usePrevious';
export { default as usePromise } from './usePromise';
export { default as useRaf } from './useRaf';
export { default as useRafLoop } from './useRafLoop';
export { default as useRefMounted } from './useRefMounted';
export { default as useScroll } from './useScroll';
export { default as useScrolling } from './useScrolling';
Expand Down
36 changes: 36 additions & 0 deletions src/useRafLoop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useRef, useState } from 'react';

export type RafLoopReturns = [() => void, boolean, () => void];

export default function useRafLoop(callback: CallableFunction): RafLoopReturns {
const raf = useRef<number | null>(null);
const [isActive, setIsActive] = useState<boolean>(true);

function loopStep() {
callback();
raf.current = requestAnimationFrame(loopStep);
}

function loopStop() {
setIsActive(false);
}

function loopStart() {
setIsActive(true);
}

function clearCurrentLoop() {
raf.current && cancelAnimationFrame(raf.current);
}

useEffect(() => clearCurrentLoop, []);

useEffect(() => {
clearCurrentLoop();
isActive && (raf.current = requestAnimationFrame(loopStep));

return clearCurrentLoop;
}, [isActive, callback]);

return [loopStop, isActive, loopStart];
}

0 comments on commit be7d7c3

Please sign in to comment.