Skip to content

Commit

Permalink
feat(useRafLoop): reworked the hook, now it do not re-render parent c…
Browse files Browse the repository at this point in the history
…omponent.

BREAKING CHANGE: changed return array, now it returns only functions in next order: [stop, start, isActive].

Parent component is not re-rendered on loop start/stop.
  • Loading branch information
xobotyi committed Apr 1, 2020
1 parent 1ef1272 commit baa2f75
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 94 deletions.
33 changes: 24 additions & 9 deletions docs/useRafLoop.md
Original file line number Diff line number Diff line change
@@ -1,26 +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.
This hook call given function within the RAF loop without re-rendering parent component.
Loop stops automatically on component unmount.

Additionally hook provides methods to start/stop loop and check current state.

## Usage

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

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

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

return (
<div>
<div>RAF triggered: {ticks} (times)</div>
<div>Last high res timestamp: {lastCall}</div>
<br />
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
<button onClick={() => {
isActive() ? loopStop() : loopStart();
update();
}}>{isActive() ? 'STOP' : 'START'}</button>
</div>
);
};
Expand All @@ -29,7 +38,13 @@ const Demo = () => {
## Reference

```ts
const [stopLoop, isActive, startLoop] = useRafLoop(callback: CallableFunction, deps?: DependencyList);
const [stopLoop, startLoop, isActive] = useRafLoop(callback: FrameRequestCallback, initiallyActive = true);
```
* `callback(time: number)` &mdash; function to call each RAF tick
* **`callback`**_: `(time: number)=>void`_ &mdash; function to call each RAF tick.
* **`time`**_: `number`_ &mdash; DOMHighResTimeStamp, which indicates the current time (based on the number of milliseconds since time origin).
* **`initiallyActive`**_: `boolean`_ &mdash; whether loop should be started at initial render.
* Return
* **`stopLoop`**_: `()=>void`_ &mdash; stop loop if it is active.
* **`startLoop`**_: `()=>void`_ &mdash; start loop if it was inactive.
* **`isActive`**_: `()=>boolean`_ &mdash; _true_ if loop is active.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test": "jest --maxWorkers 2",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint '{src,tests}/**/*.{ts,tsx}'",
"lint": "eslint {src,tests}/**/*.{ts,tsx}",
"lint:fix": "yarn lint --fix",
"lint:types": "tsc --noEmit",
"build:cjs": "tsc",
Expand Down
65 changes: 37 additions & 28 deletions src/useRafLoop.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';

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

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

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

function loopStop() {
setIsActive(false);
}

function loopStart() {
setIsActive(true);
}

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

useEffect(() => clearCurrentLoop, []);
const rafActivity = useRef<boolean>(false);
const rafCallback = useRef(callback);
rafCallback.current = callback;

const step = useCallback((time: number) => {
if (rafActivity.current) {
rafCallback.current(time);
raf.current = requestAnimationFrame(step);
}
}, []);

const result = useMemo(() => ([
() => { // stop
if (rafActivity.current) {
rafActivity.current = false;
raf.current && cancelAnimationFrame(raf.current);
}
},
() => { // start
if (!rafActivity.current) {
rafActivity.current = true;
raf.current = requestAnimationFrame(step);
}
},
(): boolean => rafActivity.current // isActive
// eslint-disable-next-line react-hooks/exhaustive-deps
] as RafLoopReturns), []);

useEffect(() => {
clearCurrentLoop();
isActive && (raf.current = requestAnimationFrame(loopStep));
if (initiallyActive) {
result[1]();
}

return clearCurrentLoop;
}, [isActive, callback]);
return result[0];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return [loopStop, isActive, loopStart];
return result;
}
15 changes: 11 additions & 4 deletions stories/useRafLoop.story.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useRafLoop } from '../src';
import { useRafLoop, useUpdate } from '../src';
import ShowDocs from './util/ShowDocs';

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

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

return (
<div>
<div>RAF triggered: {ticks} (times)</div>
<div>Last high res timestamp: {lastCall}</div>
<br />
<button onClick={isActive ? loopStop : loopStart}>{isActive ? 'STOP' : 'START'}</button>
<button onClick={() => {
isActive() ? loopStop() : loopStart();
update();
}}>{isActive() ? 'STOP' : 'START'}</button>
</div>
);
};
Expand Down
137 changes: 85 additions & 52 deletions tests/useRafLoop.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { replaceRaf } from 'raf-stub';
import useRafLoop from '../src/useRafLoop';

Expand All @@ -23,96 +23,129 @@ describe('useRafLoop', () => {
expect(useRafLoop).toBeDefined();
});

it('should return stop function, start function and loop state', () => {
it('should return object with start, stop and isActive functions', () => {
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');
expect(hook.result.current).toStrictEqual([
expect.any(Function),
expect.any(Function),
expect.any(Function),
]);
});

it('should call a callback constantly inside the raf loop', () => {
it('should constantly call callback inside the raf loop', () => {
const spy = jest.fn();
renderHook(() => useRafLoop(spy), { initialProps: false });

expect(spy).not.toBeCalled();
requestAnimationFrame.step();
requestAnimationFrame.step();
requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);
requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(4);
});

it('first element call should stop the loop', () => {
it('should not start the loop if 2nd hook parameter is falsy', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
renderHook(() => useRafLoop(spy, false), { initialProps: false });

expect(spy).not.toBeCalled();

act(() => {
hook.result.current[0]();
});
requestAnimationFrame.step();
requestAnimationFrame.step(2);
expect(spy).not.toBeCalled();
});

it('second element should represent loop state', () => {
it('should pass the time argument to given callback', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

expect(hook.result.current[1]).toBe(true);
renderHook(() => useRafLoop(spy), { initialProps: false });

// stop the loop
act(() => {
hook.result.current[0]();
});
expect(hook.result.current[1]).toBe(false);
expect(spy).not.toBeCalled();
requestAnimationFrame.step();
expect(typeof spy.mock.calls[0][0]).toBe('number');
});

it('third element call should restart loop', () => {
it('should stop the loop on component unmount', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

expect(spy).not.toBeCalled();
// stop the loop
act(() => {
hook.result.current[0]();
});
requestAnimationFrame.step();
expect(spy).not.toBeCalled();
requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);

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

requestAnimationFrame.step();
requestAnimationFrame.step();
requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);
});

it('loop should stop itself on unmount', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
it('should call the actual callback when it changed', () => {
const spy1 = jest.fn();
const spy2 = jest.fn();
const hook = renderHook(({cb}) => useRafLoop(cb), { initialProps: {cb: spy1} });

hook.unmount();
expect(spy1).not.toBeCalled();
requestAnimationFrame.step(2);
expect(spy1).toBeCalledTimes(2);

requestAnimationFrame.step();
hook.rerender({cb: spy2});

expect(spy).not.toBeCalled();
requestAnimationFrame.step(2);
expect(spy1).toBeCalledTimes(2);
expect(spy2).toBeCalledTimes(2);
});

it('should pass timestamp as 1st argument of callback', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });
describe('returned methods', () => {
it('stop method should stop the loop', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy), { initialProps: false });

requestAnimationFrame.step();
const [stop] = hook.result.current;

act(() => {
hook.result.current[0]();
expect(spy).not.toBeCalled();
requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);

stop();

requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);
});

requestAnimationFrame.step();
it('start method should start stopped loop', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy, false), { initialProps: false });

expect(spy).toHaveBeenCalled();
expect(typeof spy.mock.calls[0][0]).toBe('number');
const [stop, start] = hook.result.current;

expect(spy).not.toBeCalled();
requestAnimationFrame.step(2);
expect(spy).not.toBeCalled();

start();

requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);

stop();

requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(2);

start();

requestAnimationFrame.step(2);
expect(spy).toBeCalledTimes(4);
});

it('isActive method should return current loop state', () => {
const spy = jest.fn();
const hook = renderHook(() => useRafLoop(spy, false), { initialProps: false });

const [stop, start, isActive] = hook.result.current;

expect(isActive()).toBe(false);
start();
expect(isActive()).toBe(true);
stop();
expect(isActive()).toBe(false);
});
});
});

0 comments on commit baa2f75

Please sign in to comment.