Skip to content

Commit 1bfe063

Browse files
author
Oriol Colomer Aragonés
committed
feat: add ensuredForwardRef and useEnsuredForwardedRef
1 parent a114474 commit 1bfe063

6 files changed

+234
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@
138138
- [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo)
139139
- [`useMultiStateValidator`](./docs/useMultiStateValidator.md) — alike the `useStateValidator`, but tracks multiple states at a time. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo)
140140
- [`useMediatedState`](./docs/useMediatedState.md) — like the regular `useState` but with mediation by custom function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemediatedstate--demo)
141+
<br/>
142+
<br/>
143+
- [**Miscellaneous**]()
144+
- [`useEnsuredForwardedRef`](./docs/useEnsuredForwardedRef.md) and [`ensuredForwardRef`](./docs/useEnsuredForwardedRef.md) &mdash; use a React.forwardedRef safely. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-useensuredforwardedref--demo)
145+
141146

142147
<br />
143148
<br />

docs/useEnsuredForwardedRef.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# `useEnsuredForwardedRef`
2+
3+
React hook to use a ForwardedRef safely.
4+
5+
In some scenarios, you may need to use a _ref_ from inside and outside a component. If that's the case, you should use `React.forwardRef` to pass it through the child component. This is useful when you only want to forward that _ref_ and expose an internal `HTMLelement` to a parent component, for example. However, if you need to manipulate that reference inside a child's lifecycle hook... things get complicated, since you can't always ensure that the _ref_ is being sent by the parent component and if it is not, you will get `undefined` instead of a valid _ref_.
6+
7+
This hook is useful in this specific case, it will __ensure__ that you get a valid reference on the other side.
8+
9+
## Usage
10+
11+
```jsx
12+
import {ensuredForwardRef} from 'react-use';
13+
14+
const Demo = () => {
15+
return (
16+
<Child />
17+
);
18+
};
19+
20+
const Child = ensuredForwardRef((props, ref) => {
21+
useEffect(() => {
22+
console.log(ref.current.getBoundingClientRect())
23+
}, [])
24+
25+
return (
26+
<div ref={ref} />
27+
);
28+
});
29+
```
30+
31+
## Alternative usage
32+
33+
```jsx
34+
import {useEnsuredForwardedRef} from 'react-use';
35+
36+
const Demo = () => {
37+
return (
38+
<Child />
39+
);
40+
};
41+
42+
const Child = React.forwardRef((props, ref) => {
43+
// Here `ref` is undefined
44+
const ensuredForwardRef = useEnsuredForwardedRef(ref);
45+
// ensuredForwardRef will always be a valid reference.
46+
47+
useEffect(() => {
48+
console.log(ensuredForwardRef.current.getBoundingClientRect())
49+
}, [])
50+
51+
return (
52+
<div ref={ensuredForwardRef} />
53+
);
54+
});
55+
```
56+
57+
## Reference
58+
59+
```ts
60+
ensuredForwardRef<T, P = {}>(Component: RefForwardingComponent<T, P>): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
61+
62+
useEnsuredForwardedRef<T>(ref: React.MutableRefObject<T>): React.MutableRefObject<T>;
63+
```
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { storiesOf } from '@storybook/react';
2+
import React, { forwardRef, useRef, useState, useEffect, MutableRefObject } from 'react';
3+
import { useEnsuredForwardedRef } from '..';
4+
import ShowDocs from './util/ShowDocs';
5+
6+
import { boolean, withKnobs } from '@storybook/addon-knobs';
7+
8+
const INITIAL_SIZE = {
9+
width: null,
10+
height: null,
11+
};
12+
13+
const Demo = ({ activeForwardRef }) => {
14+
const ref = useRef(null);
15+
16+
const [size, setSize] = useState(INITIAL_SIZE);
17+
18+
useEffect(() => {
19+
handleClick();
20+
}, [activeForwardRef]);
21+
22+
const handleClick = () => {
23+
if (activeForwardRef) {
24+
const { width, height } = ref.current.getBoundingClientRect();
25+
setSize({
26+
width,
27+
height,
28+
});
29+
} else {
30+
setSize(INITIAL_SIZE);
31+
}
32+
};
33+
34+
return (
35+
<>
36+
<button onClick={handleClick} disabled={!activeForwardRef}>
37+
{activeForwardRef ? 'Update parent component' : 'forwardRef value is undefined'}
38+
</button>
39+
<div>Parent component using external ref: (textarea size)</div>
40+
<pre>{JSON.stringify(size, null, 2)}</pre>
41+
<Child ref={activeForwardRef ? ref : undefined} />
42+
</>
43+
);
44+
};
45+
46+
const Child = forwardRef(({}, ref: MutableRefObject<HTMLTextAreaElement>) => {
47+
const ensuredForwardRef = useEnsuredForwardedRef(ref);
48+
49+
const [size, setSize] = useState(INITIAL_SIZE);
50+
51+
useEffect(() => {
52+
handleMouseUp();
53+
}, []);
54+
55+
const handleMouseUp = () => {
56+
const { width, height } = ensuredForwardRef.current.getBoundingClientRect();
57+
setSize({
58+
width,
59+
height,
60+
});
61+
};
62+
63+
return (
64+
<>
65+
<div>Child forwardRef component using forwardRef: (textarea size)</div>
66+
<pre>{JSON.stringify(size, null, 2)}</pre>
67+
<div>You can resize this textarea:</div>
68+
<textarea ref={ensuredForwardRef} onMouseUp={handleMouseUp} />
69+
</>
70+
);
71+
});
72+
73+
storiesOf('Miscellaneous|useEnsuredForwardedRef', module)
74+
.addDecorator(withKnobs)
75+
.add('Docs', () => <ShowDocs md={require('../../docs/useEnsuredForwardedRef.md')} />)
76+
.add('Demo', () => {
77+
const activeForwardRef = boolean('activeForwardRef', true);
78+
return <Demo activeForwardRef={activeForwardRef} />;
79+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useRef } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import { renderHook } from '@testing-library/react-hooks';
4+
import TestUtils from 'react-dom/test-utils';
5+
import { useEnsuredForwardedRef } from '..';
6+
7+
let container: HTMLDivElement;
8+
9+
beforeEach(() => {
10+
container = document.createElement('div');
11+
document.body.appendChild(container);
12+
});
13+
14+
afterEach(() => {
15+
document.body.removeChild(container);
16+
container = null;
17+
});
18+
19+
test('should return a valid ref with existing forwardedRef', () => {
20+
const { result } = renderHook(() => {
21+
const ref = useRef(null);
22+
const ensuredRef = useEnsuredForwardedRef(ref);
23+
24+
TestUtils.act(() => {
25+
ReactDOM.render(<div ref={ensuredRef} />, container);
26+
});
27+
28+
return {
29+
initialRef: ref,
30+
ensuredForwardedRef: ensuredRef,
31+
};
32+
});
33+
34+
const { initialRef, ensuredForwardedRef } = result.current;
35+
36+
expect(ensuredForwardedRef).toStrictEqual(initialRef);
37+
});
38+
39+
test('should return a valid ref when the forwarded ref is undefined', () => {
40+
const { result } = renderHook(() => {
41+
const ref = useEnsuredForwardedRef<HTMLDivElement>(undefined);
42+
43+
TestUtils.act(() => {
44+
ReactDOM.render(<div id="test_id" ref={ref} />, container);
45+
});
46+
47+
return { ensuredRef: ref };
48+
});
49+
50+
const { ensuredRef } = result.current;
51+
52+
expect(ensuredRef.current.id).toBe('test_id');
53+
});

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { default as useDefault } from './useDefault';
1717
export { default as useDrop } from './useDrop';
1818
export { default as useDropArea } from './useDropArea';
1919
export { default as useEffectOnce } from './useEffectOnce';
20+
export { default as useEnsuredForwardedRef, ensuredForwardRef } from './useEnsuredForwardedRef';
2021
export { default as useEvent } from './useEvent';
2122
export { default as useFavicon } from './useFavicon';
2223
export { default as useFullscreen } from './useFullscreen';

src/useEnsuredForwardedRef.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
forwardRef,
3+
useRef,
4+
useEffect,
5+
MutableRefObject,
6+
ForwardRefExoticComponent,
7+
PropsWithoutRef,
8+
RefAttributes,
9+
RefForwardingComponent,
10+
PropsWithChildren,
11+
} from 'react';
12+
13+
export default function useEnsuredForwardedRef<T>(forwardedRef: MutableRefObject<T>): MutableRefObject<T> {
14+
const ensuredRef = useRef(forwardedRef && forwardedRef.current);
15+
16+
useEffect(() => {
17+
if (!forwardedRef) {
18+
return;
19+
}
20+
forwardedRef.current = ensuredRef.current;
21+
}, [forwardedRef]);
22+
23+
return ensuredRef;
24+
}
25+
26+
export function ensuredForwardRef<T, P = {}>(
27+
Component: RefForwardingComponent<T, P>
28+
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> {
29+
return forwardRef((props: PropsWithChildren<P>, ref) => {
30+
const ensuredRef = useEnsuredForwardedRef(ref as MutableRefObject<T>);
31+
return Component(props, ensuredRef);
32+
});
33+
}

0 commit comments

Comments
 (0)