Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useUpdateEffect hook #322

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ export * from './lifecycle/components/TransitionPresence/TransitionPresence.cont
export * from './lifecycle/components/TransitionPresence/TransitionPresence.js';
export * from './lifecycle/hooks/useBeforeMount/useBeforeMount.js';
export * from './lifecycle/hooks/useBeforeUnmount/useBeforeUnmount.js';
export * from './lifecycle/hooks/useIsFirstRender/useIsFirstRender.js';
export * from './lifecycle/hooks/useIsMounted/useIsMounted.js';
export * from './lifecycle/hooks/useIsMountedState/useIsMountedState.js';
export * from './lifecycle/hooks/useMount/useMount.js';
export * from './lifecycle/hooks/useUnmount/useUnmount.js';
export * from './lifecycle/hooks/useUpdateEffect/useUpdateEffect.js';
export * from './types/PolymorphicComponentProps/PolymorphicComponentProps.js';
export * from './utils/adjustFontSize/adjustFontSize.js';
export * from './utils/arrayRef/arrayRef.js';
Expand Down
42 changes: 42 additions & 0 deletions src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Meta } from '@storybook/blocks';

<Meta title="Hooks / useIsFirstRender" />

# useIsFirstRender

This hook is a useful for determining whether the current render is the first render of a component.
Its is particularly handy when you want to conditionally execute certain logic or render specific
components only on the initial render, providing an efficient way to differentiate between the first
and subsequent renders.

## Reference

```ts
function useIsFirstRender(): boolean;
```

### Returns

- `true` on first render, `false` otherwise.

## Usage

```tsx
function DemoComponent(): ReactElement {
const isFirstMount = useIsFirstRender();
const forceRerender = useForceRerender();

const onClick = useCallback(() => {
forceRerender();
}, [forceRerender]);

return (
<div>
<div>{isFirstMount ? 'This is the first render.' : 'This is not the first render.'}</div>
<button type="button" onClick={onClick}>
Rerender component
</button>
</div>
);
}
```
39 changes: 39 additions & 0 deletions src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable react/jsx-no-literals */
import type { Meta, StoryObj } from '@storybook/react';
import { useCallback, type ReactElement } from 'react';
import { useForceRerender } from '../useForceRerender/useForceRerender.js';
import { useIsFirstRender } from './useIsFirstRender.js';

const meta = {
title: 'Hooks / useIsFirstRender',
} satisfies Meta;

export default meta;

type Story = StoryObj<typeof meta>;

function DemoComponent(): ReactElement {
const isFirstMount = useIsFirstRender();
const forceRerender = useForceRerender();

const onClick = useCallback((): void => {
forceRerender();
}, [forceRerender]);

return (
<div>
<div>{isFirstMount ? 'This is the first render.' : 'This is not the first render.'}</div>
<div style={{ marginTop: '20px' }}>
<button type="button" onClick={onClick} className="btn btn-primary">
Rerender component
</button>
</div>
</div>
);
}

export const Demo: Story = {
render() {
return <DemoComponent />;
},
};
20 changes: 20 additions & 0 deletions src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useIsFirstRender } from './useIsFirstRender.js';

describe('useIsFirstRender', () => {
it('should return true on first render and false on subsequent renders', async () => {
const { result, rerender } = renderHook(() => useIsFirstRender());

// Check if the hook returns true on the first render
expect(result.current).toBe(true);

// Force a rerender
await act(async () => {
rerender();
});

// Check if the hook returns false on subsequent renders
expect(result.current).toBe(false);
});
});
16 changes: 16 additions & 0 deletions src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useRef } from 'react';

/**
* A hook that returns a boolean that is `true` only on first render.
*/
export function useIsFirstRender(): boolean {
const isFirst = useRef(true);

if (isFirst.current) {
isFirst.current = false;

return true;
}

return isFirst.current;
}
53 changes: 53 additions & 0 deletions src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Meta } from '@storybook/blocks';

<Meta title="Hooks / useUpdateEffect" />

# useUpdateEffect

A modified version of `useEffect` that is skipping the first render (mount).

## Reference

```ts
function useUpdateEffect(effect: EffectCallback, deps?: DependencyList): void;
```

### Parameters

- `effect` – Function to run on updates.
- `deps` – Dependencies list, as for `useEffect` hook

### Returns

- void

## Usage

```tsx
function DemoComponent(): ReactElement {
const [date, setDate] = useState<number>();

useEffect(() => {
// eslint-disable-next-line no-console
console.log('Normal useEffect', date);
}, [date]);

useUpdateEffect(() => {
// eslint-disable-next-line no-console
console.log('Update useEffect only', date);
}, [date]);

const onClick = useCallback(() => {
setDate(Date.now());
}, []);

return (
<div>
<p>Open your console</p>
<button type="button" onClick={onClick}>
Update
</button>
</div>
);
}
```
52 changes: 52 additions & 0 deletions src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable react/jsx-no-literals */
import type { Meta, StoryObj } from '@storybook/react';
import { useState, type ReactElement, useCallback, useEffect } from 'react';
import { useUpdateEffect } from './useUpdateEffect.js';

const meta = {
title: 'Hooks / useUpdateEffect',
} satisfies Meta;

export default meta;

type Story = StoryObj<typeof meta>;

function DemoComponent(): ReactElement {
const [date, setDate] = useState<number>();

useEffect(() => {
// eslint-disable-next-line no-console
console.log('Normal useEffect', date);
}, [date]);

useUpdateEffect(() => {
// eslint-disable-next-line no-console
console.log('Update useUpdateEffect only', date);
}, [date]);

const onClick = useCallback(() => {
setDate(Date.now());
}, []);

return (
<div>
<div>
<p>Open your console</p>
<p>
Value: <span className="badge rounded-pill bg-primary">{date ? 'true' : 'false'}</span>
</p>
</div>
<div style={{ marginTop: '20px' }}>
<button type="button" onClick={onClick} className="btn btn-primary">
Update
</button>
</div>
</div>
);
}

export const Demo: Story = {
render() {
return <DemoComponent />;
},
};
18 changes: 18 additions & 0 deletions src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { renderHook } from '@testing-library/react';
import { describe, it, vitest, expect } from 'vitest';
import { useUpdateEffect } from './useUpdateEffect.js';

describe('useUpdateEffect', () => {
it('callback function should have been called on update', () => {
const effect = vitest.fn();
const { rerender } = renderHook(() => {
useUpdateEffect(effect);
});

expect(effect).not.toHaveBeenCalled();

rerender();

expect(effect).toHaveBeenCalledTimes(1);
});
});
13 changes: 13 additions & 0 deletions src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type DependencyList, type EffectCallback, useEffect } from 'react';
import { useIsFirstRender } from '../useIsFirstRender/useIsFirstRender.js';

/**
* This hook ignores the first render, so it's not invoked on mount.
*
* @param effect Function to run on updates
* @param deps Dependencies list, as for `useEffect` hook
*/
export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList): void {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(useIsFirstRender() ? (): undefined => undefined : effect, deps);
}