Skip to content

Commit

Permalink
docs: Add new useDebounce() in /next that returns [val, isPending] (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker authored Sep 14, 2024
1 parent b3dc219 commit 13f02d3
Show file tree
Hide file tree
Showing 13 changed files with 218 additions and 21 deletions.
14 changes: 14 additions & 0 deletions .changeset/dull-deers-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@data-client/react': patch
---

New [useDebounce()](https://dataclient.io/docs/api/useDebounce) in /next that integrates useTransition()

```ts
import { useDebounce } from '@data-client/react/next';
const [debouncedQuery, isPending] = useDebounce(query, 100);
```

- Returns tuple - to include isPending
- Any Suspense triggered due to value change will continue showing
the previous contents until it is finished loading.
8 changes: 5 additions & 3 deletions docs/core/api/useDebounce.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,21 @@ function IssueList({ q, owner, repo }) {
export default React.memo(IssueList) as typeof IssueList;
```

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

export default function SearchIssues() {
const [query, setQuery] = React.useState('');
const handleChange = e => setQuery(e.currentTarget.value);
const debouncedQuery = useDebounce(query, 200);
const [debouncedQuery, isPending] = useDebounce(query, 200);
return (
<div>
<label>
Query:{' '}
<input type="text" value={query} onChange={handleChange} />
{isPending ? '...' : ''}
</label>
<AsyncBoundary fallback={<div>searching...</div>}>
<IssueList q={debouncedQuery} owner="facebook" repo="react" />
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/components/__tests__/AsyncBoundary.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('<AsyncBoundary />', () => {
it('should render children with no error', () => {
const tree = <AsyncBoundary>hi</AsyncBoundary>;
const { getByText } = render(tree);
expect(getByText(/hi/i)).toBeDefined();
expect(getByText(/hi/i)).not.toBeNull();
});
it('should render fallback when suspending', () => {
const getThing = new Endpoint(() => Promise.resolve('data'), {
Expand All @@ -43,7 +43,7 @@ describe('<AsyncBoundary />', () => {
</StrictMode>
);
const { getByText } = render(tree);
expect(getByText(/loading/i)).toBeDefined();
expect(getByText(/loading/i)).not.toBeNull();
});
it('should catch non-network errors', () => {
const originalError = console.error;
Expand All @@ -60,7 +60,7 @@ describe('<AsyncBoundary />', () => {
</AsyncBoundary>
);
const { getByText, queryByText, container } = render(tree);
expect(getByText(/you failed/i)).toBeDefined();
expect(getByText(/you failed/i)).not.toBeNull();
console.error = originalError;
expect(renderCount).toBeLessThan(10);
});
Expand All @@ -80,7 +80,7 @@ describe('<AsyncBoundary />', () => {
</AsyncBoundary>
);
const { getByText, queryByText } = render(tree);
expect(getByText(/500/i)).toBeDefined();
expect(getByText(/500/i)).not.toBeNull();
expect(queryByText(/hi/i)).toBe(null);
});
it('should render response.statusText using default fallback', () => {
Expand All @@ -98,7 +98,7 @@ describe('<AsyncBoundary />', () => {
</AsyncBoundary>
);
const { getByText, queryByText } = render(tree);
expect(getByText(/my status text/i)).toBeDefined();
expect(getByText(/my status text/i)).not.toBeNull();
expect(queryByText(/hi/i)).toBe(null);
});
});
10 changes: 5 additions & 5 deletions packages/react/src/components/__tests__/ErrorBoundary.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('<ErrorBoundary />', () => {
</ErrorBoundary>
);
const { getByText } = render(tree);
expect(getByText(/hi/i)).toBeDefined();
expect(getByText(/hi/i)).not.toBeNull();
});
it('should catch non-network errors', () => {
const originalError = console.error;
Expand All @@ -42,7 +42,7 @@ describe('<ErrorBoundary />', () => {
</ErrorBoundary>
);
const { getByText } = render(tree);
expect(getByText(/you failed/i)).toBeDefined();
expect(getByText(/you failed/i)).not.toBeNull();
console.error = originalError;
expect(renderCount).toBeLessThan(10);
});
Expand All @@ -62,7 +62,7 @@ describe('<ErrorBoundary />', () => {
</ErrorBoundary>
);
const { getByText, queryByText } = render(tree);
expect(getByText(/my status text/i)).toBeDefined();
expect(getByText(/my status text/i)).not.toBeNull();
expect(queryByText(/hi/i)).toBe(null);
});
it('should reset error when handler is called from fallback component', async () => {
Expand Down Expand Up @@ -95,13 +95,13 @@ describe('<ErrorBoundary />', () => {
</ErrorBoundary>
);
const { getByText, queryByText } = render(tree);
expect(getByText(/my status text/i)).toBeDefined();
expect(getByText(/my status text/i)).not.toBeNull();
const resetButton = queryByText('Clear Error');
expect(resetButton).not.toBeNull();
if (!resetButton) return;
shouldThrow = false;
fireEvent.press(resetButton);
expect(queryByText(/my status text/i)).toBe(null);
expect(getByText(/hi/i)).toBeDefined();
expect(getByText(/hi/i)).not.toBeNull();
});
});
2 changes: 1 addition & 1 deletion packages/react/src/components/__tests__/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('<ErrorBoundary />', () => {
it('should render children with no error', () => {
const tree = <ErrorBoundary>hi</ErrorBoundary>;
const { getByText } = render(tree);
expect(getByText(/hi/i)).toBeDefined();
expect(getByText(/hi/i)).not.toBeNull();
});
it('should catch non-network errors', () => {
const originalError = console.error;
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { useEffect, useState } from 'react';
* @param updatable Whether to update at all
* @example
```
const debouncedFilter = useDebounced(filter, 200);
const list = useSuspense(ListShape, { filter });
const debouncedFilter = useDebounce(filter, 200);
const list = useSuspense(getThings, { filter: debouncedFilter });
```
*/
export default function useDebounce<T>(
Expand Down
124 changes: 124 additions & 0 deletions packages/react/src/next/__tests__/useDebounce.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Endpoint } from '@data-client/endpoint';
import { NetworkError } from '@data-client/rest';
import { renderHook, act } from '@data-client/test';
import { render, waitFor } from '@testing-library/react';
import React, { ReactElement, StrictMode, useTransition } from 'react';

import AsyncBoundary from '../../components/AsyncBoundary';
import DataProvider from '../../components/DataProvider';
import { useSuspense } from '../../hooks';
import useDebounce from '../useDebounce';

describe('useDebounce()', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});

it('should not update until delay has passed', () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => {
return useDebounce(value, 100);
},
{ initialProps: { value: 'initial' } },
);
expect(result.current).toEqual(['initial', false]);
jest.advanceTimersByTime(10);
rerender({ value: 'next' });
rerender({ value: 'third' });
expect(result.current).toEqual(['initial', false]);
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toEqual(['third', false]);
});

it('should never update when updatable is false', () => {
const { result, rerender } = renderHook(
({ value, updatable }: { value: string; updatable: boolean }) => {
return useDebounce(value, 100, updatable);
},
{ initialProps: { value: 'initial', updatable: false } },
);
expect(result.current).toEqual(['initial', false]);
jest.advanceTimersByTime(10);
rerender({ value: 'next', updatable: false });
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toEqual(['initial', false]);
rerender({ value: 'third', updatable: true });
expect(result.current).toEqual(['initial', false]);
jest.advanceTimersByTime(10);
expect(result.current).toEqual(['initial', false]);
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toEqual(['third', false]);
});

it('should be pending while async operation is performed based on debounced value', async () => {
const issueQuery = new Endpoint(
({ q }: { q: string }) => Promise.resolve({ q, text: 'hi' }),
{ name: 'issueQuery' },
);
function IssueList({ q }: { q: string }) {
const response = useSuspense(issueQuery, { q });
return <div>{response.q}</div>;
}
function Search({ query }: { query: string }) {
const [debouncedQuery, isPending] = useDebounce(query, 100);
return (
<div>
{isPending ?
<span>loading</span>
: null}
<AsyncBoundary fallback={<div>searching...</div>}>
<IssueList q={debouncedQuery} />
</AsyncBoundary>
</div>
);
}

const tree = (
<DataProvider>
<Search query="initial" />
</DataProvider>
);
const { queryByText, rerender, getByText } = render(tree);
expect(queryByText(/loading/i)).toBeNull();
expect(getByText(/searching/i)).not.toBeNull();

await waitFor(() => expect(queryByText(/searching/i)).toBeNull());
expect(getByText(/initial/i)).not.toBeNull();
rerender(
<DataProvider>
<Search query="second" />
</DataProvider>,
);
rerender(
<DataProvider>
<Search query="third" />
</DataProvider>,
);
act(() => {
jest.advanceTimersByTime(100);
});
// only check in react 18
if ('useTransition' in React) {
// isPending
expect(getByText(/loading/i)).not.toBeNull();
}
// keep showing previous values
expect(getByText(/initial/i)).not.toBeNull();
// only check in react 18
if ('useTransition' in React) {
expect(queryByText(/searching/i)).toBeNull();
}

await waitFor(() => expect(queryByText(/loading/i)).toBeNull());
expect(getByText(/third/i)).not.toBeNull();
});
});
1 change: 1 addition & 0 deletions packages/react/src/next/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useDebounce } from './useDebounce.js';
40 changes: 40 additions & 0 deletions packages/react/src/next/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useState, useTransition } from 'react';

/**
* Keeps value updated after delay time
*
* @see https://dataclient.io/docs/api/useDebounce
* @param value Any immutable value
* @param delay Time in miliseconds to wait til updating the value
* @param updatable Whether to update at all
* @example
```
const [debouncedQuery, isPending] = useDebounce(query, 200);
const list = useSuspense(getThings, { query: debouncedQuery });
```
*/
export default function useDebounce<T>(
value: T,
delay: number,
updatable = true,
): [T, boolean] {
const [debouncedValue, setDebouncedValue] = useState(value);
const [isPending, startTransition] = useTran();

useEffect(() => {
if (!updatable) return;

const handler = setTimeout(() => {
startTransition(() => setDebouncedValue(value));
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay, updatable]);

return [debouncedValue, isPending];
}

// compatibility with older react versions
const useTran = useTransition ?? (() => [false, identityRun]);
const identityRun = (fun: (...args: any) => any) => fun();
2 changes: 2 additions & 0 deletions website/src/components/Playground/PreviewWithScope.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as graphql from '@data-client/graphql';
import * as rhReact from '@data-client/react';
import * as rhReactNext from '@data-client/react/next';
import * as rest from '@data-client/rest';
import type { Fixture, Interceptor } from '@data-client/test';
import { Temporal, Intl as PolyIntl } from '@js-temporal/polyfill';
Expand Down Expand Up @@ -36,6 +37,7 @@ const Intl = {

const scope = {
...rhReact,
...rhReactNext,
...rest,
...graphql,
uuid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,14 +485,14 @@ declare class Controller<D extends GenericDispatch = DataClientDispatch> {
*/
fetch: <E extends EndpointInterface & {
update?: EndpointUpdateFunction<E>;
}>(endpoint: E, ...args_0: Parameters<E>) => E["schema"] extends undefined | null ? ReturnType<E> : Promise<Denormalize<E["schema"]>>;
}>(endpoint: E, ...args: Parameters<E>) => E["schema"] extends undefined | null ? ReturnType<E> : Promise<Denormalize<E["schema"]>>;
/**
* Fetches only if endpoint is considered 'stale'; otherwise returns undefined
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale: <E extends EndpointInterface & {
update?: EndpointUpdateFunction<E>;
}>(endpoint: E, ...args_0: Parameters<E>) => E["schema"] extends undefined | null ? ReturnType<E> | ResolveType<E> : Promise<Denormalize<E["schema"]>> | Denormalize<E["schema"]>;
}>(endpoint: E, ...args: Parameters<E>) => E["schema"] extends undefined | null ? ReturnType<E> | ResolveType<E> : Promise<Denormalize<E["schema"]>> | Denormalize<E["schema"]>;
/**
* Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
* @see https://dataclient.io/docs/api/Controller#invalidate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ declare function useLive<E extends EndpointInterface$1<FetchFunction$1, Schema$1
* @param updatable Whether to update at all
* @example
```
const debouncedFilter = useDebounced(filter, 200);
const list = useSuspense(ListShape, { filter });
const debouncedFilter = useDebounce(filter, 200);
const list = useSuspense(getThings, { filter: debouncedFilter });
```
*/
declare function useDebounce<T>(value: T, delay: number, updatable?: boolean): T;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
/**
* Keeps value updated after delay time
*
* @see https://dataclient.io/docs/api/useDebounce
* @param value Any immutable value
* @param delay Time in miliseconds to wait til updating the value
* @param updatable Whether to update at all
* @example
```
const [debouncedQuery, isPending] = useDebounce(query, 200);
const list = useSuspense(getThings, { query: debouncedQuery });
```
*/
declare function useDebounce<T>(value: T, delay: number, updatable?: boolean): [T, boolean];

export { }
export { useDebounce };

0 comments on commit 13f02d3

Please sign in to comment.