diff --git a/src/index.ts b/src/index.ts index 815250e..c7411d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.mdx b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.mdx new file mode 100644 index 0000000..79f2963 --- /dev/null +++ b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.mdx @@ -0,0 +1,42 @@ +import { Meta } from '@storybook/blocks'; + + + +# 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 ( +
+
{isFirstMount ? 'This is the first render.' : 'This is not the first render.'}
+ +
+ ); +} +``` diff --git a/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.stories.tsx b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.stories.tsx new file mode 100644 index 0000000..198be30 --- /dev/null +++ b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.stories.tsx @@ -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; + +function DemoComponent(): ReactElement { + const isFirstMount = useIsFirstRender(); + const forceRerender = useForceRerender(); + + const onClick = useCallback((): void => { + forceRerender(); + }, [forceRerender]); + + return ( +
+
{isFirstMount ? 'This is the first render.' : 'This is not the first render.'}
+
+ +
+
+ ); +} + +export const Demo: Story = { + render() { + return ; + }, +}; diff --git a/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.test.tsx b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.test.tsx new file mode 100644 index 0000000..9bc4c39 --- /dev/null +++ b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.test.tsx @@ -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); + }); +}); diff --git a/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.ts b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.ts new file mode 100644 index 0000000..55355d3 --- /dev/null +++ b/src/lifecycle/hooks/useIsFirstRender/useIsFirstRender.ts @@ -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; +} diff --git a/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.mdx b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.mdx new file mode 100644 index 0000000..4dcabeb --- /dev/null +++ b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.mdx @@ -0,0 +1,53 @@ +import { Meta } from '@storybook/blocks'; + + + +# 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(); + + 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 ( +
+

Open your console

+ +
+ ); +} +``` diff --git a/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.stories.tsx b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.stories.tsx new file mode 100644 index 0000000..2785166 --- /dev/null +++ b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.stories.tsx @@ -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; + +function DemoComponent(): ReactElement { + const [date, setDate] = useState(); + + 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 ( +
+
+

Open your console

+

+ Value: {date ? 'true' : 'false'} +

+
+
+ +
+
+ ); +} + +export const Demo: Story = { + render() { + return ; + }, +}; diff --git a/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.test.tsx b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.test.tsx new file mode 100644 index 0000000..dd562a1 --- /dev/null +++ b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.test.tsx @@ -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); + }); +}); diff --git a/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.ts b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.ts new file mode 100644 index 0000000..72cd5f9 --- /dev/null +++ b/src/lifecycle/hooks/useUpdateEffect/useUpdateEffect.ts @@ -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); +}