Skip to content

Commit be7d7c3

Browse files
authored
feat: add useRafLoop hook
2 parents 8cb81c6 + da3628e commit be7d7c3

File tree

6 files changed

+210
-0
lines changed

6 files changed

+210
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
- [`useFavicon`](./docs/useFavicon.md) — sets favicon of the page.
9797
- [`useLocalStorage`](./docs/useLocalStorage.md) — manages a value in `localStorage`.
9898
- [`useLockBodyScroll`](./docs/useLockBodyScroll.md) — lock scrolling of the body element.
99+
- [`useRafLoop`](./docs/useRafLoop.md) — calls given function inside the RAF loop.
99100
- [`useSessionStorage`](./docs/useSessionStorage.md) — manages a value in `sessionStorage`.
100101
- [`useThrottle` and `useThrottleFn`](./docs/useThrottle.md) — throttles a function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/side-effects-usethrottle--demo)
101102
- [`useTitle`](./docs/useTitle.md) — sets title of the page.

docs/useRafLoop.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# `useRafLoop`
2+
3+
React hook that calls given function inside the RAF loop without re-rendering parent component if not needed. Loop stops automatically on component unmount.
4+
Provides controls to stop and start loop manually.
5+
6+
## Usage
7+
8+
```jsx
9+
import * as React from 'react';
10+
import { useRafLoop } from 'react-use';
11+
12+
const Demo = () => {
13+
const [ticks, setTicks] = React.useState(0);
14+
15+
const [loopStop, isActive, loopStart] = useRafLoop(() => {
16+
setTicks(ticks + 1);
17+
}, [ticks]);
18+
19+
return (
20+
<div>
21+
<div>RAF triggered: {ticks} (times)</div>
22+
<br />
23+
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
24+
</div>
25+
);
26+
};
27+
```
28+
29+
## Reference
30+
31+
```ts
32+
const [stopLoop, isActive, startLoop] = useRafLoop(callback: CallableFunction, deps?: DependencyList);
33+
```
34+
* `callback` &mdash; function to call each RAF tick
35+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { storiesOf } from '@storybook/react';
2+
import * as React from 'react';
3+
import ShowDocs from './util/ShowDocs';
4+
import { useRafLoop } from '..';
5+
6+
const Demo = () => {
7+
const [ticks, setTicks] = React.useState(0);
8+
9+
const [loopStop, isActive, loopStart] = useRafLoop(() => {
10+
setTicks(ticks + 1);
11+
});
12+
13+
return (
14+
<div>
15+
<div>RAF triggered: {ticks} (times)</div>
16+
<br />
17+
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
18+
</div>
19+
);
20+
};
21+
22+
storiesOf('Side effects|useRafLoop', module)
23+
.add('Docs', () => <ShowDocs md={require('../../docs/useRafLoop.md')} />)
24+
.add('Demo', () => <Demo />);

src/__tests__/useRafLoop.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { act, renderHook } from 'react-hooks-testing-library';
2+
import useRafLoop from '../useRafLoop';
3+
4+
describe('useRafLoop', () => {
5+
it('should be defined', () => {
6+
expect(useRafLoop).toBeDefined();
7+
});
8+
9+
it('should call a callback constantly inside the raf loop', done => {
10+
let calls = 0;
11+
const spy = () => calls++;
12+
renderHook(() => useRafLoop(spy), { initialProps: false });
13+
14+
expect(calls).toEqual(0);
15+
16+
setTimeout(() => {
17+
expect(calls).toBeGreaterThanOrEqual(5);
18+
expect(calls).toBeLessThan(10);
19+
20+
done();
21+
}, 100);
22+
});
23+
24+
it('should return stop function, start function and loop state', () => {
25+
const hook = renderHook(() => useRafLoop(() => false), { initialProps: false });
26+
27+
expect(typeof hook.result.current[0]).toEqual('function');
28+
expect(typeof hook.result.current[1]).toEqual('boolean');
29+
expect(typeof hook.result.current[2]).toEqual('function');
30+
});
31+
32+
it('first element call should stop the loop', done => {
33+
let calls = 0;
34+
const spy = () => calls++;
35+
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
36+
37+
// stop the loop
38+
act(() => {
39+
hook.result.current[0]();
40+
});
41+
42+
setTimeout(() => {
43+
expect(calls).toEqual(0);
44+
45+
done();
46+
}, 100);
47+
});
48+
49+
it('second element should represent loop state', done => {
50+
let calls = 0;
51+
const spy = () => calls++;
52+
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
53+
54+
expect(hook.result.current[1]).toBe(true);
55+
56+
// stop the loop
57+
act(() => {
58+
hook.result.current[0]();
59+
});
60+
61+
expect(hook.result.current[1]).toBe(false);
62+
setTimeout(() => {
63+
expect(calls).toEqual(0);
64+
65+
done();
66+
}, 100);
67+
});
68+
69+
it('third element call should restart loop', done => {
70+
let calls = 0;
71+
const spy = () => calls++;
72+
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
73+
74+
expect(hook.result.current[1]).toBe(true);
75+
76+
// stop the loop
77+
act(() => {
78+
hook.result.current[0]();
79+
});
80+
81+
setTimeout(() => {
82+
expect(hook.result.current[1]).toBe(false);
83+
expect(calls).toEqual(0);
84+
85+
// start the loop
86+
act(() => {
87+
hook.result.current[2]();
88+
});
89+
90+
setTimeout(() => {
91+
expect(hook.result.current[1]).toBe(true);
92+
expect(calls).toBeGreaterThanOrEqual(5);
93+
expect(calls).toBeLessThan(10);
94+
95+
done();
96+
}, 100);
97+
}, 100);
98+
});
99+
100+
it('loop should stop itself on unmount', done => {
101+
let calls = 0;
102+
const spy = () => calls++;
103+
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
104+
105+
hook.unmount();
106+
107+
setTimeout(() => {
108+
expect(calls).toEqual(0);
109+
110+
done();
111+
}, 100);
112+
});
113+
});

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export { default as usePermission } from './usePermission';
5656
export { default as usePrevious } from './usePrevious';
5757
export { default as usePromise } from './usePromise';
5858
export { default as useRaf } from './useRaf';
59+
export { default as useRafLoop } from './useRafLoop';
5960
export { default as useRefMounted } from './useRefMounted';
6061
export { default as useScroll } from './useScroll';
6162
export { default as useScrolling } from './useScrolling';

src/useRafLoop.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
export type RafLoopReturns = [() => void, boolean, () => void];
4+
5+
export default function useRafLoop(callback: CallableFunction): RafLoopReturns {
6+
const raf = useRef<number | null>(null);
7+
const [isActive, setIsActive] = useState<boolean>(true);
8+
9+
function loopStep() {
10+
callback();
11+
raf.current = requestAnimationFrame(loopStep);
12+
}
13+
14+
function loopStop() {
15+
setIsActive(false);
16+
}
17+
18+
function loopStart() {
19+
setIsActive(true);
20+
}
21+
22+
function clearCurrentLoop() {
23+
raf.current && cancelAnimationFrame(raf.current);
24+
}
25+
26+
useEffect(() => clearCurrentLoop, []);
27+
28+
useEffect(() => {
29+
clearCurrentLoop();
30+
isActive && (raf.current = requestAnimationFrame(loopStep));
31+
32+
return clearCurrentLoop;
33+
}, [isActive, callback]);
34+
35+
return [loopStop, isActive, loopStart];
36+
}

0 commit comments

Comments
 (0)