Skip to content

Commit

Permalink
Create useClientSideValue hook #116 (#160)
Browse files Browse the repository at this point in the history
* Create useClientSideValue hook #116

* Add initialValue option

* Fix eslint warning
  • Loading branch information
leroykorterink authored May 22, 2023
1 parent eb6a8af commit 1addfc3
Show file tree
Hide file tree
Showing 12 changed files with 5,478 additions and 7,622 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const { result, rerender, unmount } = renderHook(useToggle, {
// inspect the response of the hook
console.log(result.current);

await act(() => {
act(() => {
// interact with your hook
result.current[1]();
});
Expand Down
12,904 changes: 5,306 additions & 7,598 deletions package-lock.json

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/hooks/useClientSideValue/useClientSideValue.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Meta } from '@storybook/blocks';

<Meta title="hooks/useClientSideValue" />

# useClientSideValue

Hook that returns the value returned by a callback function that is only called on client-side.

## Reference

```ts
function useClientSideValue<T extends () => unknown>(callback: T): ReturnType<T> | null;
```

## Usage

Use this hook when an API is not available on the server or may return a different result on the
server to avoid hydration mismatches.

Common use cases:

- Date APIs
- Intl APIs

```tsx
type MyComponentProps = {
serverTime: Date;
};

function MyComponent({ serverTime }): ReactElement {
const now = useClientSideValue(Date.now, serverTime);

return <>{now ?? 'n/a'}</>;
}
```

```tsx
type MyComponentProps = {
bytes: number;
};

function MyComponent({ bytes }: MyComponentProps): ReactElement {
const numberFormat = useClientSideValue(
() =>
new Intl.NumberFormat(process.env.LOCALE, {
notation: 'compact',
style: 'unit',
unit: 'byte',
unitDisplay: 'narrow',
}),
);

return <>{numberFormat?.format(bytes) ?? bytes}</>;
}
```
50 changes: 50 additions & 0 deletions src/hooks/useClientSideValue/useClientSideValue.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* eslint-disable react-hooks/rules-of-hooks */
/* eslint-disable react/jsx-no-literals */
import type { StoryObj } from '@storybook/react';
import { useClientSideValue } from './useClientSideValue.js';

export default {
title: 'hooks/useClientSideValue',
};

const note = (
<div className="alert alert-secondary">
<h4 className="alert-heading">Note!</h4>
<p className="mb-0">
The initial value before the mount is different in a server-side rendered context, this is not
visible in the documentation.
</p>
</div>
);

export const Demo: StoryObj = {
render() {
const value = useClientSideValue(Date.now, 0);

return (
<>
<div>
Value: <span className="badge rounded-pill bg-primary">{value}</span>
</div>

{note}
</>
);
},
};

export const Nullable: StoryObj = {
render() {
const value = useClientSideValue<number | null>(Date.now, null);

return (
<>
<div>
Value: <span className="badge rounded-pill bg-primary">{value}</span>
</div>

{note}
</>
);
},
};
20 changes: 20 additions & 0 deletions src/hooks/useClientSideValue/useClientSideValue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { renderHook } from '@testing-library/react';
import { useClientSideValue } from './useClientSideValue.js';

describe('useClientSideValue', () => {
it('should not crash', async () => {
renderHook(() => useClientSideValue(Date.now, 0));
});

it('should set the value once', async () => {
let count = 0;

const { result, rerender } = renderHook(() => useClientSideValue(() => ++count, 0));

expect(result.current).toBe(1);

rerender();

expect(result.current).toBe(1);
});
});
22 changes: 22 additions & 0 deletions src/hooks/useClientSideValue/useClientSideValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useState } from 'react';
import { useMount } from '../useMount/useMount.js';

/**
* Hook that returns the value returned by a callback function that is only called on client-side.
*
* @example
* function MyComponent() {
* const value = useClientSideValue(Date.now, null);
*
* return <div>{value ?? 'n/a'}</div>;
* }
*/
export function useClientSideValue<T>(callback: () => T, initialValue?: T): typeof initialValue {
const [value, setValue] = useState(initialValue);

useMount(() => {
setValue(callback);
});

return value;
}
2 changes: 1 addition & 1 deletion src/hooks/useForceRerender/useForceRerender.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('useForceRerender', () => {

expect(spy).toBeCalledTimes(1);

await act(() => {
act(() => {
forceRerender();
});

Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useHasFocus/useHasFocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRef, type ReactElement } from 'react';
import { useHasFocus } from './useHasFocus.js';

describe('useHasFocus', () => {
it('should not crash', async () => {
it('should not crash', () => {
function TestComponent(): ReactElement {
const ref = useRef<HTMLDivElement>(null);
const hasFocus = useHasFocus(ref);
Expand All @@ -18,7 +18,7 @@ describe('useHasFocus', () => {
expect(result.queryByTestId('focus')).not.toBeInTheDocument();
});

it('should update when element has focus within', async () => {
it('should update when element has focus within', () => {
function TestComponent(): ReactElement {
const ref = useRef<HTMLButtonElement>(null);
const hasFocus = useHasFocus(ref, ':focus');
Expand All @@ -36,7 +36,7 @@ describe('useHasFocus', () => {

const result = render(<TestComponent />);

await act(() => {
act(() => {
result.getByTestId('button').focus();
});

Expand Down
12 changes: 6 additions & 6 deletions src/hooks/useRefs/useRefs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type TestRefs = MutableRefs<{
}>;

describe('useRefs', () => {
it('should not crash', async () => {
it('should not crash', () => {
renderHook(() => {
useRefs();
});
Expand All @@ -23,10 +23,10 @@ describe('useRefs', () => {
});
});

it('should be able to register a ref', async () => {
it('should be able to register a ref', () => {
const { result } = renderHook(() => useRefs<TestRefs>());

await act(() => {
act(() => {
result.current.number1.current = 1;
result.current.number2.current = 2;
});
Expand All @@ -37,16 +37,16 @@ describe('useRefs', () => {
});
});

it('should be able to set a ref to null', async () => {
it('should be able to set a ref to null', () => {
const { result } = renderHook(() => useRefs<TestRefs>());

await act(() => {
act(() => {
result.current.number1.current = 1;
});

expect(result.current.number1.current).toEqual(1);

await act(() => {
act(() => {
result.current.number1.current = null;
});

Expand Down
10 changes: 5 additions & 5 deletions src/hooks/useRegisterRef/useRegisterRef.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react';
import { useRegisterRef } from './useRegisterRef.js';

describe('useRegisterRef', () => {
it('should not crash', async () => {
it('should not crash', () => {
renderHook(useRegisterRef);
});

Expand All @@ -14,22 +14,22 @@ describe('useRegisterRef', () => {
expect(typeof registerRef).toBe('function');
});

it('should be able to register a ref', async () => {
it('should be able to register a ref', () => {
const { result } = renderHook(useRegisterRef);
const [refs, registerRef] = result.current;

await act(() => {
act(() => {
registerRef('item')('A');
});

expect(refs).toEqual({ item: 'A' });
});

it('should be able to register Array refs', async () => {
it('should be able to register Array refs', () => {
const { result } = renderHook(useRegisterRef);
const [refs, registerRef] = result.current;

await act(() => {
act(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
registerRef('items[]', 0)('A');
Expand Down
14 changes: 7 additions & 7 deletions src/hooks/useToggle/useToggle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,40 @@ describe('useToggle', () => {
expect(result2.current[0]).toEqual(false);
});

it('should switch the value when calling toggle', async () => {
it('should switch the value when calling toggle', () => {
const { result } = renderHook(useToggle, {
initialProps: false,
});
expect(result.current[0]).toEqual(false);

await act(() => {
act(() => {
result.current[1]();
});
expect(result.current[0]).toEqual(true);

await act(() => {
act(() => {
result.current[1]();
});
expect(result.current[0]).toEqual(false);
});

it('should set the value explicitly', async () => {
it('should set the value explicitly', () => {
const { result } = renderHook(useToggle, {
initialProps: false,
});
expect(result.current[0]).toEqual(false);

await act(() => {
act(() => {
result.current[1](false);
});
expect(result.current[0]).toEqual(false);

await act(() => {
act(() => {
result.current[1](true);
});
expect(result.current[0]).toEqual(true);

await act(() => {
act(() => {
result.current[1](true);
});
expect(result.current[0]).toEqual(true);
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export * from './components/AutoFill/AutoFill.js';
export * from './hocs/ensuredForwardRef/ensuredForwardRef.js';
export * from './hooks/useBeforeMount/useBeforeMount.js';
export * from './hooks/useClientSideValue/useClientSideValue.js';
export * from './hooks/useEventListener/useEventListener.js';
export * from './hooks/useForceRerender/useForceRerender.js';
export * from './hooks/useHasFocus/useHasFocus.js';
Expand All @@ -11,12 +12,12 @@ export * from './hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.js';
export * from './hooks/useMediaDuration/useMediaDuration.js';
export * from './hooks/useMediaQuery/useMediaQuery.js';
export * from './hooks/useMount/useMount.js';
export * from './hooks/useRefValue/useRefValue.js';
export * from './hooks/useRefs/useRefs.js';
export * from './hooks/useRefs/useRefs.types.js';
export * from './hooks/useRefs/utils/assertAndUnwrapRefs/assertAndUnwrapRefs.js';
export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.js';
export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.types.js';
export * from './hooks/useRefValue/useRefValue.js';
export * from './hooks/useRegisterRef/useRegisterRef.js';
export * from './hooks/useResizeObserver/useResizeObserver.js';
export * from './hooks/useStaticValue/useStaticValue.js';
Expand Down

0 comments on commit 1addfc3

Please sign in to comment.