Skip to content

Commit

Permalink
feat: new hook useResizeObserver
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed May 23, 2021
1 parent 5c766bf commit afa4cc0
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 0 deletions.
94 changes: 94 additions & 0 deletions src/useResizeObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { RefObject, useEffect } from 'react';
import { useSyncedRef } from './useSyncedRef';

export type IUseResizeObserverCallback = (entry: ResizeObserverEntry) => void;

interface IResizeObserverSingleton {
observer: ResizeObserver;
subscribe: (target: Element, callback: IUseResizeObserverCallback) => void;
unsubscribe: (target: Element, callback: IUseResizeObserverCallback) => void;
}

let observerSingleton: IResizeObserverSingleton;

function getResizeObserver(): IResizeObserverSingleton {
if (observerSingleton) return observerSingleton;

const callbacks = new Map<Element, Set<IUseResizeObserverCallback>>();

const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => callbacks.get(entry.target)?.forEach((cb) => cb(entry)));
});

observerSingleton = {
observer,
subscribe: (target, callback) => {
let cbs = callbacks.get(target);

if (!cbs) {
// if target has no observers yet - register it
cbs = new Set<IUseResizeObserverCallback>();
callbacks.set(target, cbs);
observer.observe(target);
}

// as Set is duplicate-safe - simply add callback on each call
cbs.add(callback);

observer.observe(target);
},
unsubscribe: (target, callback) => {
const cbs = callbacks.get(target);

if (cbs) {
// remove current observer
cbs.delete(callback);

if (!cbs.size) {
// if no observers left unregister target completely
callbacks.delete(target);
observer.unobserve(target);
}
}
},
};

return observerSingleton;
}

/**
* Invokes a callback whenever ResizeObserver detects a change to target's size.
*
* @param target React reference or Element to track.
* @param callback Callback that will be invoked on resize.
*/
export function useResizeObserver<T extends Element>(
target: RefObject<T> | T | null,
callback: IUseResizeObserverCallback
): void {
const ro = getResizeObserver();
const cb = useSyncedRef(callback);

useEffect(() => {
// as unsubscription in internals of our ResizeObserver abstraction can
// happen a bit later than effect cleanup invocation - we need a marker,
// that this handler should not be invoked anymore
let subscribed = true;

const tgt = target && 'current' in target ? target.current : target;
if (!tgt) return;

const handler: IUseResizeObserverCallback = (...args) => {
if (subscribed) cb.current(...args);
};

ro.subscribe(tgt, handler);

// eslint-disable-next-line consistent-return
return () => {
subscribed = false;
ro.unsubscribe(tgt, handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, ro]);
}
50 changes: 50 additions & 0 deletions stories/Sensor/useResizeObserver.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import { IUseResizeObserverCallback, useDebounceCallback, useResizeObserver } from '../../src';

export const Example: React.FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const [rect, setRect] = useState<DOMRectReadOnly>();
useResizeObserver(ref, (e) => setRect(e.contentRect));

return (
<div>
<pre>{JSON.stringify(rect)}</pre>
<div
ref={ref}
style={{
minWidth: 100,
minHeight: 100,
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
</div>
);
};

export const ExampleDebounced: React.FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const [rect, setRect] = useState<DOMRectReadOnly>();
const cb = useDebounceCallback<IUseResizeObserverCallback>((e) => setRect(e.contentRect), 500, [
setRect,
]);
useResizeObserver(ref, cb);

return (
<div>
<pre>{JSON.stringify(rect)}</pre>
<div
ref={ref}
style={{
minWidth: 100,
minHeight: 100,
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
</div>
);
};
42 changes: 42 additions & 0 deletions stories/Sensor/useResizeObserver.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example, ExampleDebounced } from './useResizeObserver.stories';

<Meta title="Sensor/useResizeObserver" />

# useResizeObserver

Invokes a callback whenever ResizeObserver detects a change to target's size.

- Uses a singe ResizeObserver for all hook instances, as it is more effective than using per-hook
observers.
- No need to wrap callback with `useCallback`, because hook manages callback mutation internally.
- Does not apply any throttle or debounce mechanism - it is on end-developer side.
- Does not produce references for you.
- SSR friendly.
- Provides access to `ResizeObserverEntry`

#### Example

<Canvas isColumn>
Below component uses direct invocation so it is not so optimal in terms of CPU usage, but it gains
most recent data.
<Story name="Example" story={Example} />
As `useResizeObserver` does not apply any debounce or throttle mechanisms to received callback -
it is up to developer to do so if needed passed callback. Below example is almost same as previous
but state is updated within 500ms debounce.
<Story name="ExampleDebounced" story={ExampleDebounced} />
</Canvas>

## Reference

```ts
export function useResizeObserver<T extends Element>(
target: RefObject<T> | T | null,
callback: (entry: ResizeObserverEntry) => void
): void;
```

#### Arguments

- **target** _`RefObject<Element> | Element | null`_ - element to track.
- **callback** _`(entry: ResizeObserverEntry) => void`_ - Callback that will be invoked on resize.
14 changes: 14 additions & 0 deletions tests/dom/useResizeObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useResizeObserver } from '../../src';

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

it('should render', () => {
const { result } = renderHook(() => useResizeObserver(null, () => {}));

expect(result.error).toBeUndefined();
});
});
14 changes: 14 additions & 0 deletions tests/ssr/useResizeObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useResizeObserver } from '../../src';

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

it('should render', () => {
const { result } = renderHook(() => useResizeObserver(null, () => {}));

expect(result.error).toBeUndefined();
});
});

0 comments on commit afa4cc0

Please sign in to comment.