Skip to content

Commit 13f02d3

Browse files
authored
docs: Add new useDebounce() in /next that returns [val, isPending] (#3220)
1 parent b3dc219 commit 13f02d3

File tree

13 files changed

+218
-21
lines changed

13 files changed

+218
-21
lines changed

.changeset/dull-deers-cry.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@data-client/react': patch
3+
---
4+
5+
New [useDebounce()](https://dataclient.io/docs/api/useDebounce) in /next that integrates useTransition()
6+
7+
```ts
8+
import { useDebounce } from '@data-client/react/next';
9+
const [debouncedQuery, isPending] = useDebounce(query, 100);
10+
```
11+
12+
- Returns tuple - to include isPending
13+
- Any Suspense triggered due to value change will continue showing
14+
the previous contents until it is finished loading.

docs/core/api/useDebounce.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,21 @@ function IssueList({ q, owner, repo }) {
8181
export default React.memo(IssueList) as typeof IssueList;
8282
```
8383

84-
```tsx title="SearchIssues" {7}
85-
import { useDebounce, AsyncBoundary } from '@data-client/react';
84+
```tsx title="SearchIssues" {8}
85+
import { AsyncBoundary } from '@data-client/react';
86+
import { useDebounce } from '@data-client/react/next';
8687
import IssueList from './IssueList';
8788

8889
export default function SearchIssues() {
8990
const [query, setQuery] = React.useState('');
9091
const handleChange = e => setQuery(e.currentTarget.value);
91-
const debouncedQuery = useDebounce(query, 200);
92+
const [debouncedQuery, isPending] = useDebounce(query, 200);
9293
return (
9394
<div>
9495
<label>
9596
Query:{' '}
9697
<input type="text" value={query} onChange={handleChange} />
98+
{isPending ? '...' : ''}
9799
</label>
98100
<AsyncBoundary fallback={<div>searching...</div>}>
99101
<IssueList q={debouncedQuery} owner="facebook" repo="react" />

packages/react/src/components/__tests__/AsyncBoundary.web.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('<AsyncBoundary />', () => {
2323
it('should render children with no error', () => {
2424
const tree = <AsyncBoundary>hi</AsyncBoundary>;
2525
const { getByText } = render(tree);
26-
expect(getByText(/hi/i)).toBeDefined();
26+
expect(getByText(/hi/i)).not.toBeNull();
2727
});
2828
it('should render fallback when suspending', () => {
2929
const getThing = new Endpoint(() => Promise.resolve('data'), {
@@ -43,7 +43,7 @@ describe('<AsyncBoundary />', () => {
4343
</StrictMode>
4444
);
4545
const { getByText } = render(tree);
46-
expect(getByText(/loading/i)).toBeDefined();
46+
expect(getByText(/loading/i)).not.toBeNull();
4747
});
4848
it('should catch non-network errors', () => {
4949
const originalError = console.error;
@@ -60,7 +60,7 @@ describe('<AsyncBoundary />', () => {
6060
</AsyncBoundary>
6161
);
6262
const { getByText, queryByText, container } = render(tree);
63-
expect(getByText(/you failed/i)).toBeDefined();
63+
expect(getByText(/you failed/i)).not.toBeNull();
6464
console.error = originalError;
6565
expect(renderCount).toBeLessThan(10);
6666
});
@@ -80,7 +80,7 @@ describe('<AsyncBoundary />', () => {
8080
</AsyncBoundary>
8181
);
8282
const { getByText, queryByText } = render(tree);
83-
expect(getByText(/500/i)).toBeDefined();
83+
expect(getByText(/500/i)).not.toBeNull();
8484
expect(queryByText(/hi/i)).toBe(null);
8585
});
8686
it('should render response.statusText using default fallback', () => {
@@ -98,7 +98,7 @@ describe('<AsyncBoundary />', () => {
9898
</AsyncBoundary>
9999
);
100100
const { getByText, queryByText } = render(tree);
101-
expect(getByText(/my status text/i)).toBeDefined();
101+
expect(getByText(/my status text/i)).not.toBeNull();
102102
expect(queryByText(/hi/i)).toBe(null);
103103
});
104104
});

packages/react/src/components/__tests__/ErrorBoundary.native.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('<ErrorBoundary />', () => {
2525
</ErrorBoundary>
2626
);
2727
const { getByText } = render(tree);
28-
expect(getByText(/hi/i)).toBeDefined();
28+
expect(getByText(/hi/i)).not.toBeNull();
2929
});
3030
it('should catch non-network errors', () => {
3131
const originalError = console.error;
@@ -42,7 +42,7 @@ describe('<ErrorBoundary />', () => {
4242
</ErrorBoundary>
4343
);
4444
const { getByText } = render(tree);
45-
expect(getByText(/you failed/i)).toBeDefined();
45+
expect(getByText(/you failed/i)).not.toBeNull();
4646
console.error = originalError;
4747
expect(renderCount).toBeLessThan(10);
4848
});
@@ -62,7 +62,7 @@ describe('<ErrorBoundary />', () => {
6262
</ErrorBoundary>
6363
);
6464
const { getByText, queryByText } = render(tree);
65-
expect(getByText(/my status text/i)).toBeDefined();
65+
expect(getByText(/my status text/i)).not.toBeNull();
6666
expect(queryByText(/hi/i)).toBe(null);
6767
});
6868
it('should reset error when handler is called from fallback component', async () => {
@@ -95,13 +95,13 @@ describe('<ErrorBoundary />', () => {
9595
</ErrorBoundary>
9696
);
9797
const { getByText, queryByText } = render(tree);
98-
expect(getByText(/my status text/i)).toBeDefined();
98+
expect(getByText(/my status text/i)).not.toBeNull();
9999
const resetButton = queryByText('Clear Error');
100100
expect(resetButton).not.toBeNull();
101101
if (!resetButton) return;
102102
shouldThrow = false;
103103
fireEvent.press(resetButton);
104104
expect(queryByText(/my status text/i)).toBe(null);
105-
expect(getByText(/hi/i)).toBeDefined();
105+
expect(getByText(/hi/i)).not.toBeNull();
106106
});
107107
});

packages/react/src/components/__tests__/ErrorBoundary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('<ErrorBoundary />', () => {
2020
it('should render children with no error', () => {
2121
const tree = <ErrorBoundary>hi</ErrorBoundary>;
2222
const { getByText } = render(tree);
23-
expect(getByText(/hi/i)).toBeDefined();
23+
expect(getByText(/hi/i)).not.toBeNull();
2424
});
2525
it('should catch non-network errors', () => {
2626
const originalError = console.error;

packages/react/src/hooks/useDebounce.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { useEffect, useState } from 'react';
99
* @param updatable Whether to update at all
1010
* @example
1111
```
12-
const debouncedFilter = useDebounced(filter, 200);
13-
const list = useSuspense(ListShape, { filter });
12+
const debouncedFilter = useDebounce(filter, 200);
13+
const list = useSuspense(getThings, { filter: debouncedFilter });
1414
```
1515
*/
1616
export default function useDebounce<T>(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Endpoint } from '@data-client/endpoint';
2+
import { NetworkError } from '@data-client/rest';
3+
import { renderHook, act } from '@data-client/test';
4+
import { render, waitFor } from '@testing-library/react';
5+
import React, { ReactElement, StrictMode, useTransition } from 'react';
6+
7+
import AsyncBoundary from '../../components/AsyncBoundary';
8+
import DataProvider from '../../components/DataProvider';
9+
import { useSuspense } from '../../hooks';
10+
import useDebounce from '../useDebounce';
11+
12+
describe('useDebounce()', () => {
13+
beforeAll(() => {
14+
jest.useFakeTimers();
15+
});
16+
afterAll(() => {
17+
jest.useRealTimers();
18+
});
19+
20+
it('should not update until delay has passed', () => {
21+
const { result, rerender } = renderHook(
22+
({ value }: { value: string }) => {
23+
return useDebounce(value, 100);
24+
},
25+
{ initialProps: { value: 'initial' } },
26+
);
27+
expect(result.current).toEqual(['initial', false]);
28+
jest.advanceTimersByTime(10);
29+
rerender({ value: 'next' });
30+
rerender({ value: 'third' });
31+
expect(result.current).toEqual(['initial', false]);
32+
act(() => {
33+
jest.advanceTimersByTime(100);
34+
});
35+
expect(result.current).toEqual(['third', false]);
36+
});
37+
38+
it('should never update when updatable is false', () => {
39+
const { result, rerender } = renderHook(
40+
({ value, updatable }: { value: string; updatable: boolean }) => {
41+
return useDebounce(value, 100, updatable);
42+
},
43+
{ initialProps: { value: 'initial', updatable: false } },
44+
);
45+
expect(result.current).toEqual(['initial', false]);
46+
jest.advanceTimersByTime(10);
47+
rerender({ value: 'next', updatable: false });
48+
act(() => {
49+
jest.advanceTimersByTime(100);
50+
});
51+
expect(result.current).toEqual(['initial', false]);
52+
rerender({ value: 'third', updatable: true });
53+
expect(result.current).toEqual(['initial', false]);
54+
jest.advanceTimersByTime(10);
55+
expect(result.current).toEqual(['initial', false]);
56+
act(() => {
57+
jest.advanceTimersByTime(100);
58+
});
59+
expect(result.current).toEqual(['third', false]);
60+
});
61+
62+
it('should be pending while async operation is performed based on debounced value', async () => {
63+
const issueQuery = new Endpoint(
64+
({ q }: { q: string }) => Promise.resolve({ q, text: 'hi' }),
65+
{ name: 'issueQuery' },
66+
);
67+
function IssueList({ q }: { q: string }) {
68+
const response = useSuspense(issueQuery, { q });
69+
return <div>{response.q}</div>;
70+
}
71+
function Search({ query }: { query: string }) {
72+
const [debouncedQuery, isPending] = useDebounce(query, 100);
73+
return (
74+
<div>
75+
{isPending ?
76+
<span>loading</span>
77+
: null}
78+
<AsyncBoundary fallback={<div>searching...</div>}>
79+
<IssueList q={debouncedQuery} />
80+
</AsyncBoundary>
81+
</div>
82+
);
83+
}
84+
85+
const tree = (
86+
<DataProvider>
87+
<Search query="initial" />
88+
</DataProvider>
89+
);
90+
const { queryByText, rerender, getByText } = render(tree);
91+
expect(queryByText(/loading/i)).toBeNull();
92+
expect(getByText(/searching/i)).not.toBeNull();
93+
94+
await waitFor(() => expect(queryByText(/searching/i)).toBeNull());
95+
expect(getByText(/initial/i)).not.toBeNull();
96+
rerender(
97+
<DataProvider>
98+
<Search query="second" />
99+
</DataProvider>,
100+
);
101+
rerender(
102+
<DataProvider>
103+
<Search query="third" />
104+
</DataProvider>,
105+
);
106+
act(() => {
107+
jest.advanceTimersByTime(100);
108+
});
109+
// only check in react 18
110+
if ('useTransition' in React) {
111+
// isPending
112+
expect(getByText(/loading/i)).not.toBeNull();
113+
}
114+
// keep showing previous values
115+
expect(getByText(/initial/i)).not.toBeNull();
116+
// only check in react 18
117+
if ('useTransition' in React) {
118+
expect(queryByText(/searching/i)).toBeNull();
119+
}
120+
121+
await waitFor(() => expect(queryByText(/loading/i)).toBeNull());
122+
expect(getByText(/third/i)).not.toBeNull();
123+
});
124+
});

packages/react/src/next/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as useDebounce } from './useDebounce.js';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useEffect, useState, useTransition } from 'react';
2+
3+
/**
4+
* Keeps value updated after delay time
5+
*
6+
* @see https://dataclient.io/docs/api/useDebounce
7+
* @param value Any immutable value
8+
* @param delay Time in miliseconds to wait til updating the value
9+
* @param updatable Whether to update at all
10+
* @example
11+
```
12+
const [debouncedQuery, isPending] = useDebounce(query, 200);
13+
const list = useSuspense(getThings, { query: debouncedQuery });
14+
```
15+
*/
16+
export default function useDebounce<T>(
17+
value: T,
18+
delay: number,
19+
updatable = true,
20+
): [T, boolean] {
21+
const [debouncedValue, setDebouncedValue] = useState(value);
22+
const [isPending, startTransition] = useTran();
23+
24+
useEffect(() => {
25+
if (!updatable) return;
26+
27+
const handler = setTimeout(() => {
28+
startTransition(() => setDebouncedValue(value));
29+
}, delay);
30+
return () => {
31+
clearTimeout(handler);
32+
};
33+
}, [value, delay, updatable]);
34+
35+
return [debouncedValue, isPending];
36+
}
37+
38+
// compatibility with older react versions
39+
const useTran = useTransition ?? (() => [false, identityRun]);
40+
const identityRun = (fun: (...args: any) => any) => fun();

website/src/components/Playground/PreviewWithScope.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as graphql from '@data-client/graphql';
22
import * as rhReact from '@data-client/react';
3+
import * as rhReactNext from '@data-client/react/next';
34
import * as rest from '@data-client/rest';
45
import type { Fixture, Interceptor } from '@data-client/test';
56
import { Temporal, Intl as PolyIntl } from '@js-temporal/polyfill';
@@ -36,6 +37,7 @@ const Intl = {
3637

3738
const scope = {
3839
...rhReact,
40+
...rhReactNext,
3941
...rest,
4042
...graphql,
4143
uuid,

0 commit comments

Comments
 (0)