From b07cf67d9eb1e484ec9dbda21560b2015137030c Mon Sep 17 00:00:00 2001 From: Ashish Prajapati <62009244+Ashish-simpleCoder@users.noreply.github.com> Date: Sat, 28 Dec 2024 11:01:51 +0000 Subject: [PATCH 01/81] docs: revamp the docs for readme and guideline --- CONTRIBUTING.md | 20 ++++++++++++++--- README.md | 57 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7580c6e..a558196 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,21 @@ Hi! We are really excited that you are interested in contributing to classic-react-hooks. Before submitting your contribution, please make sure to take a moment and read through the following guide: -## Repo Setup + + +## 🔧 System Requirements +- Node.js v16 or higher +- Pnpm v8 or higher + + +## 🏗️ Repo Setup + +- Clone the repository: +```bash +git clone https://github.com/Ashish-simpleCoder/classic-react-hooks.git + +cd classic-react-hooks +``` The package manager used to install and link dependencies should be [pnpm](https://pnpm.io/) v8.12.0 or higher. NodeJS version should be v18.14.2 or higher @@ -14,7 +28,7 @@ The package manager used to install and link dependencies should be [pnpm](https 4. Run `pnpm run format` to format all of the coding with prettier -## Pull Request Guidelines +## 🔃 Pull Request Guidelines - Checkout a topic branch from a base branch, e.g. `main`, and merge back against that branch. @@ -35,7 +49,7 @@ The package manager used to install and link dependencies should be [pnpm](https - Use `pnpm format` to format files according to the project guidelines. -## Documenation Guidelines +## 📄 Documenation Guidelines - To contribute in the documentation, go to apps/doc directory diff --git a/README.md b/README.md index 537715b..33a04c2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # 🚀 classic-react-hooks -#### An awesome collection of `feature packed custom hooks`. +An awesome collection of `feature` packed custom hooks.
-

npm version @@ -18,18 +17,45 @@

+
+ ## Read the Documentation https://classic-react-hooks.vercel.app/ -## Features +## ✨ Features - Comes with treeshaking - Typescript support - Small bundle size - Minimal and Easy to use -## Installation +## 🛠️ Tech Stack +- React 18 with TypeScript +- Vitepress for documentation +- Changeset for sementic version releases +- Vitest for testing the components +- tsup for build tooling + + +## ⚛️ Hook APIs + +- use-event-listener +- use-copy-to-clipboard +- use-local-storage +- use-outside-click +- use-debounced-fn +- use-throttled-hook +- use-is-online +- use-timeout-effect +- use-interval-effect +- use-synced-ref +- use-synced-effect +- use-on-mount-effect +- use-counter + + +## 🚀 Install in your project For npm users @@ -55,22 +81,13 @@ For bun users $ bun add classic-react-hooks ``` -## Hooks -- use-event-listener -- use-copy-to-clipboard -- use-local-storage -- use-outside-click -- use-debounced-fn -- use-throttled-hook -- use-is-online -- use-timeout-effect -- use-interval-effect -- use-synced-ref -- use-synced-effect -- use-on-mount-effect -- use-counter - -## Contribution +## 📝 Contribution See [Contributing Guide](https://github.com/Ashish-simpleCoder/classic-react-hooks/blob/main/CONTRIBUTING.md). + + +## 📄 License +- This project is licensed under the MIT License - see the LICENSE file for details. Say builds on top of earlier demos of how to use Whisper with Transformers.js. + + From 9e8bc78f4047d759eef65692b27c46873990cd4d Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Thu, 15 May 2025 21:32:40 +0530 Subject: [PATCH 02/81] feat[breaking]: change implementation of useEventListener and other event hooks - Change the implementation logic and api for useEventListener - Update test cases for useEventListener according to new api - Update api for useWindowResize and useOutsideClick to use useEventListener - Enable capturing as default for useOutsideClick and - Update test cases for useOutsideClick - Add types for event --- src/lib/use-event-listener/index.test.tsx | 234 +++++++++++++++++----- src/lib/use-event-listener/index.tsx | 79 ++++---- src/lib/use-outside-click/index.test.tsx | 89 ++------ src/lib/use-outside-click/index.tsx | 40 ++-- src/lib/use-window-resize/index.tsx | 7 +- src/types/index.ts | 6 + 6 files changed, 267 insertions(+), 188 deletions(-) diff --git a/src/lib/use-event-listener/index.test.tsx b/src/lib/use-event-listener/index.test.tsx index 5d4db95..ee5e369 100644 --- a/src/lib/use-event-listener/index.test.tsx +++ b/src/lib/use-event-listener/index.test.tsx @@ -1,99 +1,225 @@ -import { renderHook } from '@testing-library/react' -import { vi } from 'vitest' +import { fireEvent, render, renderHook, screen } from '@testing-library/react' +import { expect, vi } from 'vitest' import { useEventListener } from '.' +import { ElementRef, useRef, useState } from 'react' +import { EvTarget } from '../../types' -describe('use-event-listener', () => { +describe('mounting and unmounting', () => { it('should render', () => { - renderHook(() => useEventListener(null, 'click', () => {})) + renderHook(() => useEventListener(() => null, 'click', undefined, {})) + + // @ts-expect-error handling the edge case if target is not type of function + renderHook(() => useEventListener(null, 'click', undefined, {})) }) - it('should add listener on-mount and remove it on un-mount', () => { + it('should not add event if handler is not provided', () => { const div = document.createElement('div') const addSpy = vi.spyOn(div, 'addEventListener') const removeSpy = vi.spyOn(div, 'removeEventListener') + const fn = vi.fn() - const { rerender, unmount } = renderHook(() => { - useEventListener( - () => div, - 'resize', - () => {}, - { passive: true } - ) + renderHook(() => { + useEventListener(() => div, 'click') }) - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) + expect(addSpy).toHaveBeenCalledTimes(0) + expect(removeSpy).not.toHaveBeenCalled() - rerender() - expect(addSpy).toHaveBeenCalledTimes(2) - expect(removeSpy).toHaveBeenCalledTimes(1) + renderHook(() => { + useEventListener(() => div, 'click', fn, { shouldInjectEvent: false }) + }) + expect(addSpy).toHaveBeenCalledTimes(0) + expect(removeSpy).not.toHaveBeenCalled() + + renderHook(() => { + useEventListener(() => null, 'click', fn) + }) + expect(addSpy).toHaveBeenCalledTimes(0) + expect(removeSpy).not.toHaveBeenCalled() + }) + + it('should remove event on-un-mount', () => { + const div = document.createElement('div') + const addSpy = vi.spyOn(div, 'addEventListener') + const removeSpy = vi.spyOn(div, 'removeEventListener') + const fn = vi.fn() + + const { unmount } = renderHook(() => { + useEventListener(() => div, 'click', fn) + }) unmount() - expect(addSpy).toHaveBeenCalledTimes(2) - expect(removeSpy).toHaveBeenCalledTimes(2) + expect(addSpy).toHaveBeenCalledTimes(1) // should be 1 on unmount + expect(removeSpy).toHaveBeenCalledTimes(1) }) - it('should work with refs', () => { + it('should not re-run the addEventListner if the , and props are not changed', () => { const div = document.createElement('div') const addSpy = vi.spyOn(div, 'addEventListener') const removeSpy = vi.spyOn(div, 'removeEventListener') - const ref = { current: div } + const handler = vi.fn() + const target = () => div - const { rerender, unmount } = renderHook(() => { - useEventListener(ref, 'resize', () => {}, { passive: true }) + const { rerender } = renderHook(() => { + useEventListener(target, 'click', handler) }) expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) - rerender() expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) + expect(removeSpy).not.toHaveBeenCalled() + }) + + it('should re-run the effect if the , and props are changed', () => { + const div = document.createElement('div') + div.textContent = 'div' + + const addSpy = vi.spyOn(div, 'addEventListener') + const removeSpy = vi.spyOn(div, 'removeEventListener') + + const handler = vi.fn() + const t = () => div + + const { rerender, unmount } = renderHook( + (props: { capture: boolean; shouldInjectEvent: boolean; event: keyof DocumentEventMap; target: EvTarget }) => { + useEventListener(props.target, props.event, handler, { + capture: props.capture, + shouldInjectEvent: props.shouldInjectEvent, + }) + }, + { initialProps: { capture: true, target: t, event: 'click', shouldInjectEvent: true } } + ) - unmount() expect(addSpy).toHaveBeenCalledTimes(1) + + // re-render with updated options.capture prop + rerender({ capture: false, target: t, event: 'click', shouldInjectEvent: true }) + expect(addSpy).toHaveBeenCalledTimes(2) expect(removeSpy).toHaveBeenCalledTimes(1) + + // re-render with updated options.shouldInjectEvent prop + rerender({ shouldInjectEvent: false, target: t, event: 'click', capture: false }) + expect(addSpy).toHaveBeenCalledTimes(2) + expect(removeSpy).toHaveBeenCalledTimes(2) + + // re-render with updated event prop + rerender({ event: 'mousedown', shouldInjectEvent: true, capture: false, target: t }) + expect(addSpy).toHaveBeenCalledTimes(3) + expect(removeSpy).toHaveBeenCalledTimes(2) + + const div2 = document.createElement('div') + div2.textContent = 'div2' + const addSpy2 = vi.spyOn(div2, 'addEventListener') + const removeSpy2 = vi.spyOn(div2, 'removeEventListener') + + // re-render with updated target + rerender({ target: () => div2, capture: false, event: 'mousedown', shouldInjectEvent: true }) + expect(addSpy2).toHaveBeenCalledTimes(1) + expect(removeSpy).toHaveBeenCalledTimes(3) // remove old target event + + // extra re-render test same target + rerender({ target: () => div2, capture: false, event: 'mousedown', shouldInjectEvent: true }) + expect(addSpy2).toHaveBeenCalledTimes(1) + + // unmount + expect(removeSpy2).not.toHaveBeenCalled() + unmount() + expect(addSpy2).toHaveBeenCalledTimes(1) + expect(removeSpy2).toHaveBeenCalledTimes(1) }) +}) - it('should fire listener on event trigger with proper context', () => { +describe('event trigger', () => { + it('should trigger event with proper event context', () => { const div = document.createElement('div') - const listener = vi.fn() - renderHook(() => { - useEventListener(div, 'click', listener, { passive: true }) - }) + const handler = vi.fn() - const event = new Event('click') - div.dispatchEvent(event) + renderHook(() => useEventListener(() => div, 'click', handler)) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(event) + const ev = new Event('click') - div.dispatchEvent(event) - expect(listener).toHaveBeenCalledTimes(2) + // first trigger + div.dispatchEvent(ev) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(ev) + + // second trigger + div.dispatchEvent(ev) + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenCalledWith(ev) }) - it('should remove listener when shouldInjectEvent becomes false', () => { + it('should not trigger event after unmount', () => { const div = document.createElement('div') - const removeSpy = vi.spyOn(div, 'removeEventListener') + const handler = vi.fn() - const listener = vi.fn() - const { rerender } = renderHook((shouldInjectEvent: boolean = true) => { - useEventListener(div, 'click', listener, { passive: true, shouldInjectEvent }) - }) + const { unmount } = renderHook(() => useEventListener(() => div, 'click', handler)) + + // unmount + unmount() - const event = new Event('click') - div.dispatchEvent(event) + // test whether it is being triggered or not + const ev = new Event('click') + div.dispatchEvent(ev) + expect(handler).not.toHaveBeenCalled() + }) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(event) + it('should trigger event with proper event context', () => { + const div = document.createElement('div') + const handler = vi.fn() - rerender(false) - expect(listener).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(1) + renderHook(() => useEventListener(() => div, 'click', handler)) + + const ev = new Event('click') + + // first trigger + div.dispatchEvent(ev) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(ev) + + // second trigger + div.dispatchEvent(ev) + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenCalledWith(ev) + }) +}) + +describe('integration with react component', () => { + it('should log the latest value of counter in handler', () => { + const fn = vi.fn() + + const Wrapper = () => { + const [counter, setCounter] = useState(0) + const ref = useRef>(null) + useEventListener( + () => ref.current, + 'click', + () => { + fn(counter) + } + ) + + return ( +
+ +
+ log value +
+
+ ) + } + + render() + + fireEvent.click(screen.getByTestId('btn')) + fireEvent.click(screen.getByTestId('log')) + expect(fn).toHaveBeenNthCalledWith(1, 1) // should log "0" - // check whether event is cleanup or not - div.dispatchEvent(event) - expect(listener).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByTestId('btn')) + fireEvent.click(screen.getByTestId('log')) + expect(fn).toHaveBeenNthCalledWith(2, 2) // should log "1" }) }) diff --git a/src/lib/use-event-listener/index.tsx b/src/lib/use-event-listener/index.tsx index cac6d67..2ba1936 100644 --- a/src/lib/use-event-listener/index.tsx +++ b/src/lib/use-event-listener/index.tsx @@ -1,11 +1,8 @@ 'use client' -import type { Prettify } from '../../types' -import React, { RefObject, useEffect } from 'react' -import useSyncedRef from '../use-synced-ref' +import type { EvHandler, EvOptions, EvTarget } from '../../types' -export type Target = null | EventTarget | RefObject | (() => EventTarget | null) -export type Options = boolean | Prettify -export type Handler = (event: Event) => void +import React, { useEffect, useState } from 'react' +import useSyncedRef from '../use-synced-ref' /* Have taken reference from ChakraUI's use-event-listener for typing out the props in type-safe manner. */ @@ -16,55 +13,61 @@ export type Handler = (event: Event) => void * @see Docs https://classic-react-hooks.vercel.app/hooks/use-event-listener.html */ export function useEventListener( - target: Target, + target: EvTarget, event: K, handler?: (event: DocumentEventMap[K]) => void, - options?: Options + options?: EvOptions ): void export function useEventListener( - target: Target, + target: EvTarget, event: K, handler?: (event: WindowEventMap[K]) => void, - options?: Options + options?: EvOptions ): void export function useEventListener( - target: Target, + target: EvTarget, event: K, handler?: (event: GlobalEventHandlersEventMap[K]) => void, - options?: Options + options?: EvOptions ): void -export function useEventListener(target: Target, event: string, handler?: Handler, options?: Options) { +export function useEventListener(target: EvTarget, event: string, handler?: EvHandler, options?: EvOptions) { + const [elementNode, setElementNode] = useState(() => + typeof target === 'function' ? target() : null + ) + const listener = useSyncedRef({ handler, options, - }) - let shouldInjectEvent = true - if (typeof options == 'object' && 'shouldInjectEvent' in options) { - shouldInjectEvent = !!options.shouldInjectEvent - } - - useEffect(() => { - const node = typeof target === 'function' ? target() : target - - if (!listener.current.handler || !node) return + effectCb: () => { + if (!shouldInjectEvent || !listener.current.handler || !elementNode) return - const callback = (e: Event) => listener.current.handler?.(e) - const options = listener.current.options + const callback = (e: Event) => listener.current.handler?.(e) + elementNode.addEventListener(event, callback, listener.current.options) - if (shouldInjectEvent) { - if ('current' in node) { - node.current?.addEventListener(event, callback, options) - } else { - node.addEventListener(event, callback, options) + return () => { + elementNode.removeEventListener(event, callback, listener.current.options) } - } + }, + }) + let shouldInjectEvent = true, + capture, + once, + passive, + signal - return () => { - if ('current' in node) { - node.current?.removeEventListener(event, callback, options) - } else { - node.removeEventListener(event, callback, options) - } + if (typeof options == 'object') { + if ('shouldInjectEvent' in options) { + shouldInjectEvent = !!options.shouldInjectEvent } - }, [event, target, shouldInjectEvent]) + capture = options.capture + once = options.once + passive = options.passive + signal = options.signal + } + + useEffect(() => { + setElementNode(typeof target === 'function' ? target() : null) + }, [target]) + + useEffect(listener.current.effectCb, [elementNode, event, shouldInjectEvent, capture, once, passive, signal]) } diff --git a/src/lib/use-outside-click/index.test.tsx b/src/lib/use-outside-click/index.test.tsx index 2691b24..945dd47 100644 --- a/src/lib/use-outside-click/index.test.tsx +++ b/src/lib/use-outside-click/index.test.tsx @@ -2,71 +2,29 @@ import { renderHook } from '@testing-library/react' import { vi } from 'vitest' import useOutsideClick from '.' -describe('use-outside-click', () => { - it('should render', () => { - renderHook(() => useOutsideClick(null, () => {})) +describe('rendering', () => { + it('should render with null as target', () => { + // @ts-expect-error handling the edge case if target is not type of function + renderHook(() => useOutsideClick(null)) }) +}) - it('should work when Target is null', () => { - renderHook(() => useOutsideClick(null, () => {}, { shouldInjectEvent: true })) +describe('event trigger', () => { + it('should not fire listener if target is null ', () => { + // @ts-expect-error handling the edge case if target is not type of function + renderHook(() => useOutsideClick(null)) const event = new Event('click') document.dispatchEvent(event) }) - it('should add listener on-mount and remove it on un-mount', () => { - const div = document.createElement('div') - const addSpy = vi.spyOn(document, 'addEventListener') - const removeSpy = vi.spyOn(document, 'removeEventListener') - - const { rerender, unmount } = renderHook(() => { - useOutsideClick( - () => div, - () => {} - ) - }) - - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) - - rerender() - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) - - unmount() - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(1) - }) - it('should work with refs', () => { - const div = document.createElement('div') - const addSpy = vi.spyOn(document, 'addEventListener') - const removeSpy = vi.spyOn(document, 'removeEventListener') - - const ref = { current: div } - - const { rerender, unmount } = renderHook(() => { - useOutsideClick(ref, () => {}) - }) - - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) - - rerender() - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(0) - - unmount() - expect(addSpy).toHaveBeenCalledTimes(1) - expect(removeSpy).toHaveBeenCalledTimes(1) - }) - - it('should fire listener when clicked outside of target element when ref is provided', () => { + it('should fire listener when clicked outside of target element', () => { const div = document.createElement('div') const ref = { current: div } const listener = vi.fn() renderHook(() => { - useOutsideClick(ref, listener) + useOutsideClick(() => ref.current, listener) }) const event = new Event('click') @@ -76,22 +34,7 @@ describe('use-outside-click', () => { expect(listener).toHaveBeenCalledWith(event) }) - it('should fire listener when clicked outside of target element', () => { - const div = document.createElement('div') - - const listener = vi.fn() - renderHook(() => { - useOutsideClick(() => div, listener) - }) - - const event = new Event('click') - document.dispatchEvent(event) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(event) - }) - - it('should not fire listener when clicked on target element or inside within it', () => { + it('should not fire listener when clicked on target element or inside within that target element. But fire when clicked outside of the target element', () => { const div = document.createElement('div') const span = document.createElement('span') @@ -103,11 +46,17 @@ describe('use-outside-click', () => { useOutsideClick(() => div, listener) }) - const event = new Event('click', { bubbles: true }) + const event = new Event('click') div.dispatchEvent(event) expect(listener).toHaveBeenCalledTimes(0) span.dispatchEvent(event) expect(listener).toHaveBeenCalledTimes(0) + + const ev = new Event('click') + document.dispatchEvent(ev) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(ev) }) }) diff --git a/src/lib/use-outside-click/index.tsx b/src/lib/use-outside-click/index.tsx index 9d4ea54..20f2c8a 100644 --- a/src/lib/use-outside-click/index.tsx +++ b/src/lib/use-outside-click/index.tsx @@ -1,8 +1,8 @@ 'use client' -import type { Target } from '../use-event-listener' -import React, { useRef } from 'react' +import type { EvOptions, EvTarget } from '../../types' + +import React from 'react' import { useEventListener } from '../use-event-listener' -import useSyncedRef from '../use-synced-ref' /** * @description @@ -11,32 +11,22 @@ import useSyncedRef from '../use-synced-ref' * @see Docs https://classic-react-hooks.vercel.app/hooks/use-outside-click.html */ export default function useOutsideClick( - target: Target, - handler: (event: DocumentEventMap['click']) => void, - options?: { shouldInjectEvent?: boolean | any } + target: EvTarget, + handler?: (event: DocumentEventMap['click']) => void, + options?: EvOptions ) { - const paramsRef = useSyncedRef({ - target, - handler, - }) - let shouldInjectEvent = true - if (typeof options == 'object' && 'shouldInjectEvent' in options) { - shouldInjectEvent = !!options.shouldInjectEvent - } + const eventCb = (event: DocumentEventMap['click']) => { + const node = typeof target == 'function' ? target() : null // node which need to be tracked if click has occured within it or not - const eventCb = useRef((event: DocumentEventMap['click']) => { - const node = (typeof target == 'function' ? target() : target) ?? document - if (event.target == node || ('current' in node && event.target == node.current)) return + if (!node) return - if ( - node && - (('contains' in node && (node as Node).contains(event.target as Node)) || - ('current' in node && 'contains' && (node.current as Node).contains(event.target as Node))) - ) { + if (event.target == node) return + + if ('contains' in node && (node as Node).contains(event.target as Node)) { return } - paramsRef.current.handler(event) - }) + handler?.(event) + } - useEventListener(document, 'click', eventCb.current, { shouldInjectEvent: shouldInjectEvent }) + useEventListener(() => document, 'click', eventCb, { capture: true, ...(options ?? {}) }) } diff --git a/src/lib/use-window-resize/index.tsx b/src/lib/use-window-resize/index.tsx index 49d5fa7..a5bb537 100644 --- a/src/lib/use-window-resize/index.tsx +++ b/src/lib/use-window-resize/index.tsx @@ -11,7 +11,12 @@ import { useEventListener } from '../use-event-listener' export default function useWindowResize(cb: () => T, options?: { defaultValue?: T; shouldInjectEvent?: boolean }) { const [result, setResult] = useState(options?.defaultValue ?? cb) - useEventListener(window, 'resize', () => setResult(cb), { shouldInjectEvent: options?.shouldInjectEvent ?? true }) + useEventListener( + () => window, + 'resize', + () => setResult(cb), + { shouldInjectEvent: options?.shouldInjectEvent ?? true } + ) return result } diff --git a/src/types/index.ts b/src/types/index.ts index 948daa1..6963922 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,9 @@ export type Prettify = { [Key in keyof K]: K[Key] } & {} + +export type EvTarget = () => EventTarget | null +export interface EvOptions extends AddEventListenerOptions { + shouldInjectEvent?: boolean | any +} +export type EvHandler = (event: Event) => void From 4b1a877bac92a7fa713b3ae6700cfb56ed627df7 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Thu, 15 May 2025 21:34:28 +0530 Subject: [PATCH 03/81] test: update test for useWindowResize hook for function branch to cover 100% --- src/lib/use-window-resize/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/use-window-resize/index.test.tsx b/src/lib/use-window-resize/index.test.tsx index c011735..dff9af2 100644 --- a/src/lib/use-window-resize/index.test.tsx +++ b/src/lib/use-window-resize/index.test.tsx @@ -9,7 +9,7 @@ describe('use-window-resize', () => { }) it('should return defaultValue, if defaultValue is passed', () => { - const { result } = renderHook(() => useWindowResize(() => window.innerWidth < 400, { defaultValue: true })) + const { result } = renderHook(() => useWindowResize(vi.fn(), { defaultValue: true })) expect(result.current).toBe(true) }) From 1085f545d17d600648870e5ef9baeb3eb4dc3119 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Sat, 17 May 2025 10:17:06 +0530 Subject: [PATCH 04/81] test: update test config include glob pattern to watch only .tsx and .ts extensions --- vitest.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index d5adfc5..e803ff2 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -15,7 +15,7 @@ export default defineConfig({ include: ['src/lib/**/*'], exclude: ['src/lib/use-combined-key-event-listener'], }, - include: ['src/lib/**/*.{test,spec}.{js,jsx,ts,tsx}'], + include: ['src/lib/**/*.test.{tsx,ts}'], exclude: [ '**/node_modules/**', '**/dist/**', From cddb76b08d15e43d5b208dd8ed475a26f27e2cc1 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Sat, 17 May 2025 12:02:43 +0530 Subject: [PATCH 05/81] feat: change api for event hooks - Use object based params for useEventListener, useOutsideClick and useWindowResize hooks - Update all of the test suite useEventListener, useOutsideClick and useWindoeResize hooks according to new api --- src/lib/use-event-listener/index.test.tsx | 85 ++++++++++++++--------- src/lib/use-event-listener/index.tsx | 57 ++++++++++----- src/lib/use-outside-click/index.test.tsx | 24 +++---- src/lib/use-outside-click/index.tsx | 22 ++++-- src/lib/use-window-resize/index.test.tsx | 24 ++++--- src/lib/use-window-resize/index.tsx | 25 ++++--- 6 files changed, 151 insertions(+), 86 deletions(-) diff --git a/src/lib/use-event-listener/index.test.tsx b/src/lib/use-event-listener/index.test.tsx index ee5e369..a236b5f 100644 --- a/src/lib/use-event-listener/index.test.tsx +++ b/src/lib/use-event-listener/index.test.tsx @@ -6,10 +6,16 @@ import { EvTarget } from '../../types' describe('mounting and unmounting', () => { it('should render', () => { - renderHook(() => useEventListener(() => null, 'click', undefined, {})) + renderHook(() => + useEventListener({ + target: () => null, + event: 'click', + options: {}, + }) + ) // @ts-expect-error handling the edge case if target is not type of function - renderHook(() => useEventListener(null, 'click', undefined, {})) + renderHook(() => useEventListener({ target: null, event: 'click' })) }) it('should not add event if handler is not provided', () => { @@ -19,20 +25,28 @@ describe('mounting and unmounting', () => { const fn = vi.fn() renderHook(() => { - useEventListener(() => div, 'click') + useEventListener({ + target: () => div, + event: 'click', + }) }) expect(addSpy).toHaveBeenCalledTimes(0) expect(removeSpy).not.toHaveBeenCalled() renderHook(() => { - useEventListener(() => div, 'click', fn, { shouldInjectEvent: false }) + useEventListener({ + target: () => div, + event: 'click', + handler: fn, + options: { shouldInjectEvent: false }, + }) }) expect(addSpy).toHaveBeenCalledTimes(0) expect(removeSpy).not.toHaveBeenCalled() renderHook(() => { - useEventListener(() => null, 'click', fn) + useEventListener({ target: () => null, event: 'click', handler: fn }) }) expect(addSpy).toHaveBeenCalledTimes(0) expect(removeSpy).not.toHaveBeenCalled() @@ -45,7 +59,7 @@ describe('mounting and unmounting', () => { const fn = vi.fn() const { unmount } = renderHook(() => { - useEventListener(() => div, 'click', fn) + useEventListener({ target: () => div, event: 'click', handler: fn }) }) unmount() @@ -58,11 +72,10 @@ describe('mounting and unmounting', () => { const addSpy = vi.spyOn(div, 'addEventListener') const removeSpy = vi.spyOn(div, 'removeEventListener') - const handler = vi.fn() - const target = () => div + const fn = vi.fn() const { rerender } = renderHook(() => { - useEventListener(target, 'click', handler) + useEventListener({ target: () => div, event: 'click', handler: fn }) }) expect(addSpy).toHaveBeenCalledTimes(1) @@ -83,9 +96,14 @@ describe('mounting and unmounting', () => { const { rerender, unmount } = renderHook( (props: { capture: boolean; shouldInjectEvent: boolean; event: keyof DocumentEventMap; target: EvTarget }) => { - useEventListener(props.target, props.event, handler, { - capture: props.capture, - shouldInjectEvent: props.shouldInjectEvent, + useEventListener({ + target: props.target, + event: props.event, + handler: handler, + options: { + capture: props.capture, + shouldInjectEvent: props.shouldInjectEvent, + }, }) }, { initialProps: { capture: true, target: t, event: 'click', shouldInjectEvent: true } } @@ -133,28 +151,29 @@ describe('mounting and unmounting', () => { describe('event trigger', () => { it('should trigger event with proper event context', () => { const div = document.createElement('div') - const handler = vi.fn() + const fn = vi.fn() - renderHook(() => useEventListener(() => div, 'click', handler)) + renderHook(() => useEventListener({ target: () => div, event: 'click', handler: fn })) const ev = new Event('click') // first trigger div.dispatchEvent(ev) - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(ev) + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(ev) // second trigger div.dispatchEvent(ev) - expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenCalledWith(ev) + expect(fn).toHaveBeenCalledTimes(2) + expect(fn).toHaveBeenCalledWith(ev) }) it('should not trigger event after unmount', () => { const div = document.createElement('div') - const handler = vi.fn() + const fn = vi.fn() - const { unmount } = renderHook(() => useEventListener(() => div, 'click', handler)) + // const { unmount } = renderHook(() => useEventListener(() => div, 'click', handler)) + const { unmount } = renderHook(() => useEventListener({ target: () => div, event: 'click', handler: fn })) // unmount unmount() @@ -162,26 +181,26 @@ describe('event trigger', () => { // test whether it is being triggered or not const ev = new Event('click') div.dispatchEvent(ev) - expect(handler).not.toHaveBeenCalled() + expect(fn).not.toHaveBeenCalled() }) it('should trigger event with proper event context', () => { const div = document.createElement('div') - const handler = vi.fn() + const fn = vi.fn() - renderHook(() => useEventListener(() => div, 'click', handler)) + renderHook(() => useEventListener({ target: () => div, event: 'click', handler: fn })) const ev = new Event('click') // first trigger div.dispatchEvent(ev) - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(ev) + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(ev) // second trigger div.dispatchEvent(ev) - expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenCalledWith(ev) + expect(fn).toHaveBeenCalledTimes(2) + expect(fn).toHaveBeenCalledWith(ev) }) }) @@ -192,13 +211,13 @@ describe('integration with react component', () => { const Wrapper = () => { const [counter, setCounter] = useState(0) const ref = useRef>(null) - useEventListener( - () => ref.current, - 'click', - () => { + useEventListener({ + target: () => ref.current, + event: 'click', + handler: () => { fn(counter) - } - ) + }, + }) return (
diff --git a/src/lib/use-event-listener/index.tsx b/src/lib/use-event-listener/index.tsx index 2ba1936..a1990b8 100644 --- a/src/lib/use-event-listener/index.tsx +++ b/src/lib/use-event-listener/index.tsx @@ -12,25 +12,50 @@ import useSyncedRef from '../use-synced-ref' * * @see Docs https://classic-react-hooks.vercel.app/hooks/use-event-listener.html */ -export function useEventListener( - target: EvTarget, - event: K, - handler?: (event: DocumentEventMap[K]) => void, +export function useEventListener({ + target, + event, + handler, + options, +}: { + target: EvTarget + event: K + handler?: (event: DocumentEventMap[K]) => void options?: EvOptions -): void -export function useEventListener( - target: EvTarget, - event: K, - handler?: (event: WindowEventMap[K]) => void, +}): void +export function useEventListener({ + target, + event, + handler, + options, +}: { + target: EvTarget + event: K + handler?: (event: WindowEventMap[K]) => void options?: EvOptions -): void -export function useEventListener( - target: EvTarget, - event: K, - handler?: (event: GlobalEventHandlersEventMap[K]) => void, +}): void +export function useEventListener({ + target, + event, + handler, + options, +}: { + target: EvTarget + event: K + handler?: (event: GlobalEventHandlersEventMap[K]) => void options?: EvOptions -): void -export function useEventListener(target: EvTarget, event: string, handler?: EvHandler, options?: EvOptions) { +}): void +export function useEventListener({ + target, + event, + handler, + options, +}: { + target: EvTarget + event: string + handler?: EvHandler + options?: EvOptions +}) { const [elementNode, setElementNode] = useState(() => typeof target === 'function' ? target() : null ) diff --git a/src/lib/use-outside-click/index.test.tsx b/src/lib/use-outside-click/index.test.tsx index 945dd47..e2ccd86 100644 --- a/src/lib/use-outside-click/index.test.tsx +++ b/src/lib/use-outside-click/index.test.tsx @@ -5,14 +5,14 @@ import useOutsideClick from '.' describe('rendering', () => { it('should render with null as target', () => { // @ts-expect-error handling the edge case if target is not type of function - renderHook(() => useOutsideClick(null)) + renderHook(() => useOutsideClick({ target: null })) }) }) describe('event trigger', () => { it('should not fire listener if target is null ', () => { // @ts-expect-error handling the edge case if target is not type of function - renderHook(() => useOutsideClick(null)) + renderHook(() => useOutsideClick({ target: null })) const event = new Event('click') document.dispatchEvent(event) @@ -21,17 +21,17 @@ describe('event trigger', () => { it('should fire listener when clicked outside of target element', () => { const div = document.createElement('div') const ref = { current: div } - const listener = vi.fn() + const fn = vi.fn() renderHook(() => { - useOutsideClick(() => ref.current, listener) + useOutsideClick({ target: () => ref.current, handler: fn }) }) const event = new Event('click') document.dispatchEvent(event) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(event) + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(event) }) it('should not fire listener when clicked on target element or inside within that target element. But fire when clicked outside of the target element', () => { @@ -41,22 +41,22 @@ describe('event trigger', () => { div.append(span) document.body.append(div) - const listener = vi.fn() + const fn = vi.fn() renderHook(() => { - useOutsideClick(() => div, listener) + useOutsideClick({ target: () => div, handler: fn }) }) const event = new Event('click') div.dispatchEvent(event) - expect(listener).toHaveBeenCalledTimes(0) + expect(fn).toHaveBeenCalledTimes(0) span.dispatchEvent(event) - expect(listener).toHaveBeenCalledTimes(0) + expect(fn).toHaveBeenCalledTimes(0) const ev = new Event('click') document.dispatchEvent(ev) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(ev) + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(ev) }) }) diff --git a/src/lib/use-outside-click/index.tsx b/src/lib/use-outside-click/index.tsx index 20f2c8a..e98981b 100644 --- a/src/lib/use-outside-click/index.tsx +++ b/src/lib/use-outside-click/index.tsx @@ -10,11 +10,15 @@ import { useEventListener } from '../use-event-listener' * * @see Docs https://classic-react-hooks.vercel.app/hooks/use-outside-click.html */ -export default function useOutsideClick( - target: EvTarget, - handler?: (event: DocumentEventMap['click']) => void, +export default function useOutsideClick({ + target, + handler, + options, +}: { + target: EvTarget + handler?: (event: DocumentEventMap['click']) => void options?: EvOptions -) { +}) { const eventCb = (event: DocumentEventMap['click']) => { const node = typeof target == 'function' ? target() : null // node which need to be tracked if click has occured within it or not @@ -28,5 +32,13 @@ export default function useOutsideClick( handler?.(event) } - useEventListener(() => document, 'click', eventCb, { capture: true, ...(options ?? {}) }) + useEventListener({ + target: () => document, + event: 'click', + handler: eventCb, + options: { + capture: true, + ...options, + }, + }) } diff --git a/src/lib/use-window-resize/index.test.tsx b/src/lib/use-window-resize/index.test.tsx index dff9af2..e6f7cc1 100644 --- a/src/lib/use-window-resize/index.test.tsx +++ b/src/lib/use-window-resize/index.test.tsx @@ -1,22 +1,24 @@ import { renderHook } from '@testing-library/react' import { vi } from 'vitest' import useWindowResize from '.' -import { act } from 'react-dom/test-utils' +import { act } from 'react' describe('use-window-resize', () => { it('should run without errors', () => { - renderHook(() => useWindowResize(() => window.innerWidth < 400)) + renderHook(() => useWindowResize({ handler: () => window.innerWidth < 400 })) }) it('should return defaultValue, if defaultValue is passed', () => { - const { result } = renderHook(() => useWindowResize(vi.fn(), { defaultValue: true })) + const { result } = renderHook(() => useWindowResize({ handler: vi.fn(), options: { defaultValue: true } })) expect(result.current).toBe(true) }) it('should update the result when window is resized', () => { const { result } = renderHook(() => - useWindowResize(() => { - return window.innerWidth < 400 + useWindowResize({ + handler: () => { + return window.innerWidth < 400 + }, }) ) expect(result.current).toBe(false) @@ -36,21 +38,21 @@ describe('use-window-resize', () => { it('should remove resize event when shouldInjectEvent becomes false', () => { let shouldInjectEvent = true - const cb = vi.fn() + const fn = vi.fn() - const { rerender } = renderHook(() => useWindowResize(cb, { shouldInjectEvent })) - expect(cb).toHaveBeenCalledTimes(1) + const { rerender } = renderHook(() => useWindowResize({ handler: fn, options: { shouldInjectEvent } })) + expect(fn).toHaveBeenCalledTimes(1) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(cb).toHaveBeenCalledTimes(2) - expect(cb).toHaveBeenCalledTimes(2) + expect(fn).toHaveBeenCalledTimes(2) + expect(fn).toHaveBeenCalledTimes(2) shouldInjectEvent = false rerender() act(() => { window.dispatchEvent(new Event('resize')) }) - expect(cb).toHaveBeenCalledTimes(2) + expect(fn).toHaveBeenCalledTimes(2) }) }) diff --git a/src/lib/use-window-resize/index.tsx b/src/lib/use-window-resize/index.tsx index a5bb537..62264e0 100644 --- a/src/lib/use-window-resize/index.tsx +++ b/src/lib/use-window-resize/index.tsx @@ -8,15 +8,22 @@ import { useEventListener } from '../use-event-listener' * * @see Docs https://classic-react-hooks.vercel.app/hooks/use-window-resize.html */ -export default function useWindowResize(cb: () => T, options?: { defaultValue?: T; shouldInjectEvent?: boolean }) { - const [result, setResult] = useState(options?.defaultValue ?? cb) - - useEventListener( - () => window, - 'resize', - () => setResult(cb), - { shouldInjectEvent: options?.shouldInjectEvent ?? true } - ) +export default function useWindowResize({ + handler, + options, +}: { + handler: () => T + options?: { defaultValue?: T; shouldInjectEvent?: boolean } +}) { + const [result, setResult] = useState(options?.defaultValue ?? handler) + useEventListener({ + target: () => window, + event: 'resize', + handler: () => setResult(handler), + options: { + shouldInjectEvent: options?.shouldInjectEvent ?? true, + }, + }) return result } From 0754d8c581cc9a9df5e44f9a3a21625c7660cfb4 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Sat, 24 May 2025 12:38:31 +0530 Subject: [PATCH 06/81] feat: update api for use-intersection-hook - use object based params instead of sequential params - update the test --- .../use-intersection-observer/index.test.tsx | 4 +- src/lib/use-intersection-observer/index.tsx | 71 ++++++++++--------- src/types/index.ts | 8 +++ 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/lib/use-intersection-observer/index.test.tsx b/src/lib/use-intersection-observer/index.test.tsx index 6ea0b43..f54aac0 100644 --- a/src/lib/use-intersection-observer/index.test.tsx +++ b/src/lib/use-intersection-observer/index.test.tsx @@ -26,13 +26,13 @@ describe('use-intersection-observer', () => { it('should run without error', () => { const div = document.createElement('div') - renderHook(() => useInterSectionObserver([div])) + renderHook(() => useInterSectionObserver({targets:[()=>div]})) }) it('should call disconnect on un-mount', () => { const div = document.createElement('div') - const { unmount } = renderHook(() => useInterSectionObserver([div])) + const { unmount } = renderHook(() => useInterSectionObserver({targets:[()=>div]})) expect(IntersectionObserverSpy.mock.results[0]?.value.disconnect).toHaveBeenCalledTimes(0) unmount() diff --git a/src/lib/use-intersection-observer/index.tsx b/src/lib/use-intersection-observer/index.tsx index 10fcfe9..f92e220 100644 --- a/src/lib/use-intersection-observer/index.tsx +++ b/src/lib/use-intersection-observer/index.tsx @@ -1,25 +1,26 @@ -import type { RefObject } from 'react' -import type { Prettify } from '../../types' +import type { IntersectionOptions, IntersectionObserverTarget, IsTargetIntersecting } from '../../types' -import { useState, useEffect } from 'react' - -export type Target = HTMLElement | RefObject | (() => HTMLElement | null) | null - -type Options = { - mode?: 'lazy' | 'virtualized' -} & IntersectionObserverInit +import { useEffect, useState } from 'react' /** * @description * A hook which provides a way for listening to the Intersection Observer event for given target. * - * It takes an array of targets and returns an array of boolean values which represents whether the targets are intersecting the screen or not. + * It takes an array of targets and returns an array of boolean values which represents whether the targets are intersecting to the screen or not. * * @see Docs https://classic-react-hooks.vercel.app/hooks/use-intersection-observer.html */ -export default function useInterSectionObserver(targets: Target[], options: Prettify = {}): Array { +export default function useInterSectionObserver({ + targets, + options = { only_trigger_once: true }, + onIntersection, +}: { + targets: IntersectionObserverTarget[] + options?: IntersectionOptions + onIntersection?: (target: Element) => void +}): Array { const [visibilityStates, setVisiblilityStates] = useState(() => { - return new Array(targets.length).fill(false) as Array + return new Array(targets.length).fill(false) as Array }) const intersection_options: IntersectionObserverInit = { @@ -28,33 +29,41 @@ export default function useInterSectionObserver(targets: Target[], options: Pret threshold: options.threshold, } - if (!options.mode) { - options.mode = 'lazy' - } - useEffect(() => { if (!window.IntersectionObserver) { console.warn('IntersectionObserver is not available.') return } + options = { + only_trigger_once: true, + ...options, + } const io = new IntersectionObserver((entries) => { entries.forEach((entry) => { - const entry_idx = entry.target.getAttribute('idx') + const entry_idx = Number(entry.target.getAttribute('idx') ?? -1) // assign -1 to ignore the observation + if (entry.isIntersecting) { setVisiblilityStates((_visibilityState) => { - if (entry_idx == null) return _visibilityState + if (entry_idx == -1) return _visibilityState - _visibilityState[+entry_idx] = true + _visibilityState[entry_idx] = true return [..._visibilityState] }) - if (options.mode == 'lazy') { + // unobserve the target in each iteration if only_trigger_once is true + if (options.only_trigger_once == true) { io.unobserve(entry.target) + } else if (Array.isArray(options.only_trigger_once)) { + if (entry_idx != -1 && options.only_trigger_once[entry_idx] == true) + // if for an specific element, only_trigger_once is true, then unobserve it + io.unobserve(entry.target) } + // callback to run after element is visible on screen + onIntersection?.(entry.target) } else { setVisiblilityStates((_visibilityState) => { - if (entry_idx == null) return _visibilityState + if (entry_idx == -1) return _visibilityState - _visibilityState[+entry_idx] = false + _visibilityState[entry_idx] = false return [..._visibilityState] }) } @@ -63,21 +72,13 @@ export default function useInterSectionObserver(targets: Target[], options: Pret targets.forEach((element, idx) => observer(element, idx)) - function observer(element: Target, idx: number) { - let target: HTMLElement | null = null - + function observer(element: IntersectionObserverTarget, idx: number) { try { - if (element && 'current' in element) { - target = element.current - } else if (typeof element == 'function') { + if (typeof element == 'function') { const ele = element() - target = ele - } else { - target = element - } - if (target) { - target.setAttribute('idx', idx.toString()) - io.observe(target) + if (!ele || !(ele instanceof Element)) return + ele.setAttribute('idx', idx.toString()) + io.observe(ele) } } catch (err) { console.warn(err) diff --git a/src/types/index.ts b/src/types/index.ts index 6963922..22d1546 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,3 +7,11 @@ export interface EvOptions extends AddEventListenerOptions { shouldInjectEvent?: boolean | any } export type EvHandler = (event: Event) => void + + +// use-intersection type +export interface IntersectionOptions extends IntersectionObserverInit { + only_trigger_once?: boolean | Array +} +export type IntersectionObserverTarget = () => HTMLElement | null +export type IsTargetIntersecting = boolean From ee1c492b222e539418561c0cd0ebd9a79b4d9087 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Sun, 25 May 2025 11:33:16 +0530 Subject: [PATCH 07/81] feat: change api for use-local-storage hook --- src/lib/use-intersection-observer/index.test.tsx | 4 ++-- src/lib/use-local-storage/index.test.tsx | 15 ++++++++------- src/lib/use-local-storage/index.tsx | 2 +- src/types/index.ts | 1 - 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/use-intersection-observer/index.test.tsx b/src/lib/use-intersection-observer/index.test.tsx index f54aac0..b197ff5 100644 --- a/src/lib/use-intersection-observer/index.test.tsx +++ b/src/lib/use-intersection-observer/index.test.tsx @@ -26,13 +26,13 @@ describe('use-intersection-observer', () => { it('should run without error', () => { const div = document.createElement('div') - renderHook(() => useInterSectionObserver({targets:[()=>div]})) + renderHook(() => useInterSectionObserver({ targets: [() => div] })) }) it('should call disconnect on un-mount', () => { const div = document.createElement('div') - const { unmount } = renderHook(() => useInterSectionObserver({targets:[()=>div]})) + const { unmount } = renderHook(() => useInterSectionObserver({ targets: [() => div] })) expect(IntersectionObserverSpy.mock.results[0]?.value.disconnect).toHaveBeenCalledTimes(0) unmount() diff --git a/src/lib/use-local-storage/index.test.tsx b/src/lib/use-local-storage/index.test.tsx index 1a153b9..4aae78b 100644 --- a/src/lib/use-local-storage/index.test.tsx +++ b/src/lib/use-local-storage/index.test.tsx @@ -1,5 +1,6 @@ -import { act, renderHook } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import useLocalStorage from '.' +import { act } from 'react' describe('use-local-storage', () => { afterEach(() => { @@ -7,7 +8,7 @@ describe('use-local-storage', () => { }) it('should return state and setState', () => { - const { result } = renderHook(() => useLocalStorage('user')) + const { result } = renderHook(() => useLocalStorage({ key: 'user' })) expect(result.current[0]).toBe('') expect(typeof result.current[1]).toBe('function') @@ -15,24 +16,24 @@ describe('use-local-storage', () => { it('should be able handle error when item is undefined in local-storage', () => { localStorage.setItem('key', '') // when getting localStorage.getItem(key) => '', it results into undefined - const { result } = renderHook(() => useLocalStorage('key')) + const { result } = renderHook(() => useLocalStorage({ key: 'key' })) expect(result.current[0]).toBeUndefined() }) it('should be able handle error when with default value param', () => { localStorage.setItem('key', '') - const { result } = renderHook(() => useLocalStorage('key', {})) + const { result } = renderHook(() => useLocalStorage({ key: 'key', defaultValue: {} })) expect(result.current[0]).toStrictEqual({}) }) it('should set the default value', () => { - const { result } = renderHook(() => useLocalStorage('user', { name: 'Saitama' })) + const { result } = renderHook(() => useLocalStorage({ key: 'user', defaultValue: { name: 'Saitama' } })) expect(result.current[0]).toEqual({ name: 'Saitama' }) }) it('should update the state in local storage', () => { - const { result, rerender } = renderHook(() => useLocalStorage('user', { name: 'Saitama' })) + const { result, rerender } = renderHook(() => useLocalStorage({ key: 'user', defaultValue: { name: 'Saitama' } })) act(() => { result.current[1]({ name: 'Genos' }) @@ -43,7 +44,7 @@ describe('use-local-storage', () => { }) it('should be able to handle function in setState', () => { - const { result, rerender } = renderHook(() => useLocalStorage('user', { name: 'Saitama' })) + const { result, rerender } = renderHook(() => useLocalStorage({ key: 'user', defaultValue: { name: 'Saitama' } })) act(() => { result.current[1]((old_value) => { diff --git a/src/lib/use-local-storage/index.tsx b/src/lib/use-local-storage/index.tsx index 52e9892..826732a 100644 --- a/src/lib/use-local-storage/index.tsx +++ b/src/lib/use-local-storage/index.tsx @@ -13,7 +13,7 @@ import React, { useRef, useState } from 'react' * * @see Docs https://classic-react-hooks.vercel.app/hooks/use-local-storage.html */ -export default function useLocalStorage(key: string, defaultValue?: State) { +export default function useLocalStorage({ key, defaultValue }: { key: string; defaultValue?: State }) { const [state, setState] = useState(() => { try { const item = localStorage.getItem(key) diff --git a/src/types/index.ts b/src/types/index.ts index 22d1546..6a5c19e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,7 +8,6 @@ export interface EvOptions extends AddEventListenerOptions { } export type EvHandler = (event: Event) => void - // use-intersection type export interface IntersectionOptions extends IntersectionObserverInit { only_trigger_once?: boolean | Array From dfd1d8da82972f361a35251859b3f2362bb2eaed Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Thu, 29 May 2025 20:47:29 +0530 Subject: [PATCH 08/81] feat: change api for use-debounced-fn --- src/lib/use-debounced-fn/index.test.tsx | 16 +++++++------- src/lib/use-debounced-fn/index.tsx | 29 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/lib/use-debounced-fn/index.test.tsx b/src/lib/use-debounced-fn/index.test.tsx index 8f5fce2..b16b6cc 100644 --- a/src/lib/use-debounced-fn/index.test.tsx +++ b/src/lib/use-debounced-fn/index.test.tsx @@ -12,21 +12,21 @@ describe('use-debounced-fn', () => { it('should return the debounced callback', () => { const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn(callback, 300)) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) expect(typeof result.current).toBe('function') }) it('should not fire callback on initialization', () => { const callback = vi.fn() - renderHook(() => useDebouncedFn(callback, 300)) + renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) expect(callback).not.toHaveBeenCalled() }) it('should return the debounced callback with default 300ms delay', async () => { const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn(callback)) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) result.current(10) vi.advanceTimersByTime(100) @@ -41,7 +41,7 @@ describe('use-debounced-fn', () => { it('should call deboucnced function with given arguments', async () => { const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn(callback)) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) result.current(10) vi.advanceTimersByTime(300) @@ -55,7 +55,7 @@ describe('use-debounced-fn', () => { it('should debounce the callback with custom delay', () => { const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn(callback, 500)) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) result.current(2) vi.advanceTimersByTime(300) @@ -75,7 +75,7 @@ describe('use-debounced-fn', () => { it('should cleanup the timers on unmount', async () => { const callback = vi.fn() - const { result, unmount } = renderHook(() => useDebouncedFn(callback, 500)) + const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) result.current() unmount() @@ -87,7 +87,7 @@ describe('use-debounced-fn', () => { let delay = 200 const callback = vi.fn() - const { result, rerender } = renderHook(() => useDebouncedFn(callback, delay)) + const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay })) result.current() vi.advanceTimersByTime(200) expect(callback).toHaveBeenCalledTimes(1) @@ -118,7 +118,7 @@ describe('use-debounced-fn', () => { console.log(tempValue) }) - const { result, rerender } = renderHook(() => useDebouncedFn(callback)) + const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) result.current() vi.advanceTimersByTime(300) diff --git a/src/lib/use-debounced-fn/index.tsx b/src/lib/use-debounced-fn/index.tsx index b51a672..c8a5c61 100644 --- a/src/lib/use-debounced-fn/index.tsx +++ b/src/lib/use-debounced-fn/index.tsx @@ -11,19 +11,28 @@ const DEFAULT_DELAY = 300 * @see Docs https://classic-react-hooks.vercel.app/hooks/use-debounced-fn.html * */ -export default function useDebouncedFn any>(cb: T, delay = DEFAULT_DELAY) { +export default function useDebouncedFn any>({ + callbackToBounce, + delay = DEFAULT_DELAY, +}: { + callbackToBounce: T + delay?: number +}) { const paramsRef = useSyncedRef({ - cb, + callbackToBounce, delay, }) const timerId = useRef() const debouncedCb = useRef({ - fn: (...args: Parameters) => { + fn: (...args: Parameters) => { if (timerId.current) { clearTimeout(timerId.current) } - timerId.current = setTimeout(() => paramsRef.current.cb.call(null, ...args), paramsRef.current.delay) + timerId.current = setTimeout( + () => paramsRef.current.callbackToBounce.call(null, ...args), + paramsRef.current.delay + ) }, cleanup: () => clearTimeout(timerId.current), }) @@ -42,15 +51,21 @@ export default function useDebouncedFn any>(cb: T, * A wrapper function which returns debounced version of passed callback. * If needed to work outside of react, then use this wrapper function. */ -export function debouncedFnWrapper any>(cb: T, delay = DEFAULT_DELAY) { +export function debouncedFnWrapper any>({ + callbackToBounce, + delay = DEFAULT_DELAY, +}: { + callbackToBounce: T + delay?: number +}) { let timerId: NodeJS.Timeout return { - fn: (...args: Parameters) => { + fn: (...args: Parameters) => { if (timerId) { clearTimeout(timerId) } - timerId = setTimeout(() => cb.call(null, ...args), delay) + timerId = setTimeout(() => callbackToBounce.call(null, ...args), delay) }, cleanup: () => clearTimeout(timerId), } From c406948eedb4db969a981d7c11ed537c4307f7cf Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Fri, 30 May 2025 20:35:26 +0530 Subject: [PATCH 09/81] feat: change api implementation for useDebouncedFn and useThrottledFn --- src/lib/use-debounced-fn/index.test.tsx | 569 ++++++++++++++++++++---- src/lib/use-debounced-fn/index.tsx | 42 +- src/lib/use-throttled-fn/index.test.tsx | 385 +++++++++++++--- src/lib/use-throttled-fn/index.tsx | 43 +- 4 files changed, 855 insertions(+), 184 deletions(-) diff --git a/src/lib/use-debounced-fn/index.test.tsx b/src/lib/use-debounced-fn/index.test.tsx index b16b6cc..9404d18 100644 --- a/src/lib/use-debounced-fn/index.test.tsx +++ b/src/lib/use-debounced-fn/index.test.tsx @@ -1,135 +1,526 @@ import { vi } from 'vitest' import { renderHook } from '@testing-library/react' +import { act } from 'react' import useDebouncedFn from '.' -describe('use-debounced-fn', () => { +describe('useDebouncedFn', () => { beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() + vi.clearAllMocks() }) - it('should return the debounced callback', () => { - const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + describe('Basic functionality', () => { + it('should return a function', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + + expect(typeof result.current).toBe('function') + }) + + it('should not call callback on initialization', () => { + const callback = vi.fn() + renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + + expect(callback).not.toHaveBeenCalled() + }) + + it('should not call callback immediately when invoked', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + + act(() => { + result.current() + }) - expect(typeof result.current).toBe('function') + expect(callback).not.toHaveBeenCalled() + }) }) - it('should not fire callback on initialization', () => { - const callback = vi.fn() - renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + describe('Debouncing behavior', () => { + it('should use default delay of 300ms', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + act(() => { + result.current() + }) + + act(() => { + vi.advanceTimersByTime(299) + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) // Total 300ms + }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should respect custom delay', () => { + const callback = vi.fn() + const customDelay = 500 + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: customDelay })) + + act(() => { + result.current() + }) - expect(callback).not.toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(499) + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) // Total 500ms + }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should debounce multiple rapid calls', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) + + act(() => { + result.current() // Call 1 + result.current() // Call 2 - should reset timer + result.current() // Call 3 - should reset timer + }) + + act(() => { + vi.advanceTimersByTime(100) + result.current() // Call 4 - should reset timer again + }) + + act(() => { + vi.advanceTimersByTime(199) + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) // 200ms from last call + }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should allow multiple executions after delay periods', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 100 })) + + // First execution + act(() => { + result.current('first') + }) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenNthCalledWith(1, 'first') + + // Second execution + act(() => { + result.current('second') + }) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'second') + }) }) - it('should return the debounced callback with default 300ms delay', async () => { - const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + describe('Arguments handling', () => { + it('should pass arguments correctly to the callback', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + act(() => { + result.current('arg1', 'arg2', 123) + }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 123) + }) + + it('should use arguments from the latest call', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) + + act(() => { + result.current('first') + result.current('second') + result.current('third') // This should be the final call + }) + + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('third') + }) + + it('should preserve argument references', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + const originalObj = { value: 'original' } + + act(() => { + result.current(originalObj) + }) + + // Modify the object before debounce executes + originalObj.value = 'modified' + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback).toHaveBeenCalledWith(originalObj) + expect(callback.mock.calls?.[0]?.[0].value).toBe('modified') + }) + }) + + describe('Context binding', () => { + it('should not preserve this context (calls with null)', () => { + let capturedThis: any = 'not-set' + const testObj = { + name: 'test', + callback: function () { + capturedThis = this + }, + } + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: testObj.callback })) + + act(() => { + result.current.call(testObj) // Try to set context + }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + // Your implementation calls with null, so this should be null/undefined + expect(capturedThis).toBeNull() + }) + }) + + describe('Hook updates and re-renders', () => { + it('should update callback when it changes', () => { + const callback1 = vi.fn() + const callback2 = vi.fn(() => 'second') + + const { result, rerender } = renderHook(({ callback }) => useDebouncedFn({ callbackToBounce: callback }), { + initialProps: { callback: callback1 }, + }) + + act(() => { + result.current() + }) + + // Update the callback before execution + rerender({ callback: callback2 }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback1).not.toHaveBeenCalled() + expect(callback2).toHaveBeenCalledTimes(1) + }) + + it('should update delay and cleanup previous timer', () => { + const callback = vi.fn() + + const { result, rerender } = renderHook(({ delay }) => useDebouncedFn({ callbackToBounce: callback, delay }), { + initialProps: { delay: 200 }, + }) + + act(() => { + result.current() + }) - result.current(10) - vi.advanceTimersByTime(100) - expect(callback).toHaveBeenCalledTimes(0) + // Update delay - this should cleanup the existing timer + rerender({ delay: 500 }) + + act(() => { + vi.advanceTimersByTime(200) // Original delay time + }) + expect(callback).not.toHaveBeenCalled() // Should be cancelled + + // New call with updated delay + act(() => { + result.current() + }) + + act(() => { + vi.advanceTimersByTime(500) // New delay time + }) + expect(callback).toHaveBeenCalledTimes(1) + }) - vi.advanceTimersByTime(100) - expect(callback).toHaveBeenCalledTimes(0) + it('should handle callback updates during pending execution', () => { + let message = 'original' + const logFn = vi.fn() + const createCallback = () => + vi.fn(() => { + logFn(message) + return message + }) - vi.advanceTimersByTime(100) - expect(callback).toHaveBeenCalledTimes(1) + let callback = createCallback() + const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + act(() => { + result.current() + }) + + // Update both message and callback + message = 'updated' + callback = createCallback() + rerender() + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(logFn).toHaveBeenCalledTimes(1) + expect(logFn).toHaveBeenNthCalledWith(1, 'updated') + }) }) - it('should call deboucnced function with given arguments', async () => { - const callback = vi.fn() - const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + describe('Cleanup and unmounting', () => { + it('should cleanup timer on unmount', () => { + const callback = vi.fn() + + const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) + + act(() => { + result.current() + }) + + unmount() + + act(() => { + vi.advanceTimersByTime(600) + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('should cleanup multiple pending timers on unmount', () => { + const callback = vi.fn() + + const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) + + act(() => { + result.current() // First call + vi.advanceTimersByTime(100) + result.current() // Second call (cancels first) + vi.advanceTimersByTime(100) + result.current() // Third call (cancels second) + }) + + unmount() + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('should not cause memory leaks with repeated mount/unmount', () => { + const callback = vi.fn() + + for (let i = 0; i < 10; i++) { + const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 100 })) + + act(() => { + result.current() + }) - result.current(10) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenNthCalledWith(1, 10) + unmount() + } - result.current(30) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenNthCalledWith(2, 30) + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(callback).not.toHaveBeenCalled() + }) }) - it('should debounce the callback with custom delay', () => { - const callback = vi.fn() + describe('Error handling', () => { + it('should handle callback that throws an error', () => { + const errorCallback = vi.fn(() => { + throw new Error('Test error') + }) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: errorCallback })) + + act(() => { + result.current() + }) + + expect(() => { + act(() => { + vi.advanceTimersByTime(300) + }) + }).toThrow('Test error') + + expect(errorCallback).toHaveBeenCalledTimes(1) + }) + + it('should continue working after callback error', () => { + let shouldThrow = true + const callback = vi.fn(() => { + if (shouldThrow) { + throw new Error('Test error') + } + return 'success' + }) - const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - result.current(2) - vi.advanceTimersByTime(300) - result.current(2) - result.current(2) - vi.advanceTimersByTime(500) + // First call throws + act(() => { + result.current() + }) - expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith(2) + expect(() => { + act(() => { + vi.advanceTimersByTime(300) + }) + }).toThrow('Test error') - result.current(5) - vi.advanceTimersByTime(500) - expect(callback).toHaveBeenCalledTimes(2) - expect(callback).toHaveBeenCalledWith(2) + // Second call succeeds + shouldThrow = false + act(() => { + result.current() + }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) }) - it('should cleanup the timers on unmount', async () => { - const callback = vi.fn() + describe('Edge cases', () => { + it('should handle rapid delay changes', () => { + const callback = vi.fn() + const delays = [100, 200, 50, 500, 300] + let currentDelay = delays[0] + + const { result, rerender } = renderHook(() => + useDebouncedFn({ callbackToBounce: callback, delay: currentDelay }) + ) - const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) + delays.forEach((delay, index) => { + currentDelay = delay + rerender() - result.current() - unmount() - vi.advanceTimersByTime(600) - expect(callback).toHaveBeenCalledTimes(0) + act(() => { + result.current(`call-${index}`) + }) + }) + + act(() => { + vi.advanceTimersByTime(300) // Final delay + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('call-4') + }) }) - it('should sync with updated delay param and cleanup the timers with it', () => { - let delay = 200 - const callback = vi.fn() - - const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay })) - result.current() - vi.advanceTimersByTime(200) - expect(callback).toHaveBeenCalledTimes(1) - - // should run with updated timer - delay = 500 - rerender() - result.current() - vi.advanceTimersByTime(200) - expect(callback).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledTimes(2) - - // should cleanup scheduled call when timer is updated - result.current() - delay = 1000 - rerender() - vi.advanceTimersByTime(500) - expect(callback).toHaveBeenCalledTimes(2) + describe('Performance and memory', () => { + it('should not create new debounced function on every render', () => { + const callback = vi.fn() + const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + const firstDebouncedFn = result.current + rerender() + const secondDebouncedFn = result.current + + expect(firstDebouncedFn).toBe(secondDebouncedFn) + }) + + it('should handle many rapid calls efficiently', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 100 })) + + act(() => { + // Simulate many rapid calls + for (let i = 0; i < 1000; i++) { + result.current(i) + } + }) + + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(999) // Last call's argument + }) }) - it('callback param should be reactive', () => { - let tempValue = 'temp' + describe('Integration with React lifecycle', () => { + it('should work correctly with React.StrictMode (double effect execution)', () => { + const callback = vi.fn() + + // Simulate StrictMode by manually calling effects twice + const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) + + // Simulate StrictMode re-render + rerender() + + act(() => { + result.current() + }) - vi.spyOn(console, 'log') + act(() => { + vi.advanceTimersByTime(200) + }) - const callback = vi.fn(() => { - console.log(tempValue) + expect(callback).toHaveBeenCalledTimes(1) }) - const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + it('should handle component re-renders during debounce period', () => { + const callback = vi.fn() + let renderCount = 0 - result.current() - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledTimes(1) - expect(console.log).toHaveBeenCalledWith('temp') + const { result, rerender } = renderHook(() => { + renderCount++ + return useDebouncedFn({ callbackToBounce: callback, delay: 300 }) + }) - tempValue = 'this is temp' - rerender() - result.current() - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledTimes(2) - expect(console.log).toHaveBeenCalledWith('this is temp') + act(() => { + result.current() + }) + + // Force multiple re-renders during debounce period + act(() => { + vi.advanceTimersByTime(100) + rerender() + vi.advanceTimersByTime(100) + rerender() + vi.advanceTimersByTime(100) // Total 300ms + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(renderCount).toBeGreaterThan(1) + }) }) }) diff --git a/src/lib/use-debounced-fn/index.tsx b/src/lib/use-debounced-fn/index.tsx index c8a5c61..dac6b30 100644 --- a/src/lib/use-debounced-fn/index.tsx +++ b/src/lib/use-debounced-fn/index.tsx @@ -1,6 +1,5 @@ 'use client' import React, { useEffect, useRef } from 'react' -import useSyncedRef from '../use-synced-ref' const DEFAULT_DELAY = 300 @@ -13,29 +12,22 @@ const DEFAULT_DELAY = 300 */ export default function useDebouncedFn any>({ callbackToBounce, - delay = DEFAULT_DELAY, + delay, }: { callbackToBounce: T delay?: number }) { - const paramsRef = useSyncedRef({ + const paramsRef = useRef({ callbackToBounce, delay, }) - const timerId = useRef() - const debouncedCb = useRef({ - fn: (...args: Parameters) => { - if (timerId.current) { - clearTimeout(timerId.current) - } - timerId.current = setTimeout( - () => paramsRef.current.callbackToBounce.call(null, ...args), - paramsRef.current.delay - ) - }, - cleanup: () => clearTimeout(timerId.current), - }) + // tracking props with immutable object + paramsRef.current.delay = delay + paramsRef.current.callbackToBounce = callbackToBounce + + // so can access the updated props inside debouncedFnWrapper function + const debouncedCb = useRef(debouncedFnWrapper(paramsRef.current)) useEffect(() => { return () => { @@ -51,21 +43,21 @@ export default function useDebouncedFn any>({ * A wrapper function which returns debounced version of passed callback. * If needed to work outside of react, then use this wrapper function. */ -export function debouncedFnWrapper any>({ - callbackToBounce, - delay = DEFAULT_DELAY, -}: { - callbackToBounce: T - delay?: number -}) { +export function debouncedFnWrapper any>(props: { callbackToBounce: T; delay?: number }) { let timerId: NodeJS.Timeout return { - fn: (...args: Parameters) => { + fn: (...args: Parameters) => { if (timerId) { clearTimeout(timerId) } - timerId = setTimeout(() => callbackToBounce.call(null, ...args), delay) + timerId = setTimeout(() => { + try { + props.callbackToBounce.call(null, ...args) + } catch (err) { + throw err + } + }, props.delay ?? DEFAULT_DELAY) }, cleanup: () => clearTimeout(timerId), } diff --git a/src/lib/use-throttled-fn/index.test.tsx b/src/lib/use-throttled-fn/index.test.tsx index 869e41f..0cc5d4d 100644 --- a/src/lib/use-throttled-fn/index.test.tsx +++ b/src/lib/use-throttled-fn/index.test.tsx @@ -1,95 +1,364 @@ import { vi } from 'vitest' import { renderHook } from '@testing-library/react' +import { act } from 'react' import useThrottledFn from '.' -describe('use-throttled-fn', () => { +describe('useThrottledFn', () => { beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() + vi.clearAllMocks() }) - it('should return the throttled callback', () => { - const callback = vi.fn() - const { result } = renderHook(() => useThrottledFn(callback, 300)) + describe('Basic functionality', () => { + it('should return a function', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + expect(typeof result.current).toBe('function') + }) + + it('should not call callback on initialization', () => { + const callback = vi.fn() + renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + expect(callback).not.toHaveBeenCalled() + }) + + it('should call callback immediately on first invocation', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + act(() => { + result.current() + }) + + expect(callback).toHaveBeenCalledTimes(1) + }) + }) + + describe('Throttling behavior', () => { + it('should use default delay of 300ms', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + act(() => { + result.current() + result.current() // Should be ignored + }) + + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(299) + result.current() // Should still be ignored + }) + + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(1) // Total 300ms + result.current() // Should be called + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should respect custom delay', () => { + const callback = vi.fn() + const customDelay = 500 + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: customDelay })) + + act(() => { + result.current() + result.current() // Should be ignored + }) + + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(499) + result.current() // Should still be ignored + }) + + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(1) // Total 500ms + result.current() // Should be called + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should throttle multiple rapid calls', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: 100 })) + + act(() => { + result.current() // Call 1 - should execute + result.current() // Call 2 - should be throttled + result.current() // Call 3 - should be throttled + result.current() // Call 4 - should be throttled + }) + + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(100) + result.current() // Call 5 - should execute + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should allow execution after delay has passed', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: 200 })) + + act(() => { + result.current() + }) + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(200) + result.current() + }) + expect(callback).toHaveBeenCalledTimes(2) - expect(typeof result.current).toBe('function') + act(() => { + vi.advanceTimersByTime(200) + result.current() + }) + expect(callback).toHaveBeenCalledTimes(3) + }) }) - it('should not fire callback on initialization', () => { - const callback = vi.fn() - renderHook(() => useThrottledFn(callback, 300)) + describe('Arguments handling', () => { + it('should pass arguments correctly to the callback', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) - expect(callback).not.toHaveBeenCalled() + act(() => { + result.current('arg1', 'arg2', 123) + }) + + expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 123) + }) + + it('should pass different arguments on subsequent calls', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: 100 })) + + act(() => { + result.current('first') + }) + expect(callback).toHaveBeenNthCalledWith(1, 'first') + + act(() => { + vi.advanceTimersByTime(100) + result.current('second') + }) + expect(callback).toHaveBeenNthCalledWith(2, 'second') + }) }) - it('should return the throttled callback with default 300ms delay', async () => { - const callback = vi.fn() - const { result } = renderHook(() => useThrottledFn(callback)) + describe('Context binding', () => { + it('should preserve this context when called with call()', () => { + let capturedThis: any = null + const testObj = { + name: 'test', + callback: function () { + capturedThis = this + }, + } - result.current(10) - expect(callback).toHaveBeenCalledTimes(1) + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: testObj.callback })) - result.current(10) - vi.advanceTimersByTime(500) + act(() => { + result.current.call(testObj) + }) - result.current(10) - expect(callback).toHaveBeenCalledTimes(2) + expect(capturedThis).toBe(testObj) + }) - result.current(10) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledTimes(2) + it('should preserve this context when called with apply()', () => { + let capturedThis: any = null + const testObj = { + name: 'test', + callback: function (arg: string) { + capturedThis = this + return arg + }, + } - result.current(10) - vi.advanceTimersByTime(1000) - expect(callback).toHaveBeenCalledTimes(3) + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: testObj.callback })) + + act(() => { + result.current.apply(testObj, ['test-arg']) + }) + + expect(capturedThis).toBe(testObj) + }) }) - it('should call throttled function with given arguments', async () => { - const callback = vi.fn() - const { result } = renderHook(() => useThrottledFn(callback)) - result.current(10) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenNthCalledWith(1, 10) + describe('Hook updates and re-renders', () => { + it('should update callback when it changes', () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + const { result, rerender } = renderHook(({ callback }) => useThrottledFn({ callbackToThrottle: callback }), { + initialProps: { callback: callback1 }, + }) + + act(() => { + result.current() + }) + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).not.toHaveBeenCalled() + + // Update the callback + rerender({ callback: callback2 }) + + act(() => { + vi.advanceTimersByTime(300) + result.current() + }) + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(1) + }) + + it('should update delay when it changes', () => { + const callback = vi.fn() + + const { result, rerender } = renderHook( + ({ delay }) => useThrottledFn({ callbackToThrottle: callback, delay }), + { initialProps: { delay: 100 } } + ) + + act(() => { + result.current() + }) + expect(callback).toHaveBeenCalledTimes(1) + + // Update delay + rerender({ delay: 500 }) + + act(() => { + vi.advanceTimersByTime(100) // Old delay + result.current() // Should still be throttled with new delay + }) + expect(callback).toHaveBeenCalledTimes(1) - result.current(30) - vi.advanceTimersByTime(300) - expect(callback).toHaveBeenNthCalledWith(2, 30) + act(() => { + vi.advanceTimersByTime(400) // Total 500ms (new delay) + result.current() + }) + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should maintain throttle state across re-renders', () => { + const callback = vi.fn() + + const { result, rerender } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: 200 })) + + act(() => { + result.current() + }) + expect(callback).toHaveBeenCalledTimes(1) + + // Re-render the component + rerender() + + act(() => { + result.current() // Should still be throttled + }) + expect(callback).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(200) + result.current() // Should now execute + }) + expect(callback).toHaveBeenCalledTimes(2) + }) }) - it('should throttle the callback with custom delay', async () => { - const callback = vi.fn() + describe('Error handling', () => { + it('should handle callback that throws an error', () => { + const errorCallback = vi.fn(() => { + throw new Error('Test error') + }) + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: errorCallback })) - const { result } = renderHook(() => useThrottledFn(callback, 500)) + expect(() => { + act(() => { + result.current() + }) + }).toThrow('Test error') - result.current(2) - vi.advanceTimersByTime(300) - result.current(2) - result.current(2) - vi.advanceTimersByTime(500) + expect(errorCallback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenNthCalledWith(1, 2) + // Should still throttle after error + expect(() => { + act(() => { + result.current() + }) + }).not.toThrow() - result.current(5) - vi.advanceTimersByTime(500) - expect(callback).toHaveBeenCalledTimes(2) - expect(callback).toHaveBeenNthCalledWith(2, 5) + expect(errorCallback).toHaveBeenCalledTimes(1) // Still throttled + }) }) - it('should bind the this context', () => { - let res: any = '' - const obj = { - details: 'this is details', - callback: function () { - res = this - }, - } + describe('Performance and memory', () => { + it('should not create new throttled function on every render', () => { + const callback = vi.fn() + const { result, rerender } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + const firstDebouncedFn = result.current + rerender() + const secondDebouncedFn = result.current + + expect(firstDebouncedFn).toBe(secondDebouncedFn) + }) + }) + + describe('Memory and performance', () => { + it('should not create new throttled function on every render', () => { + const callback = vi.fn() + const { result, rerender } = renderHook(() => useThrottledFn({ callbackToThrottle: callback })) + + const firstThrottledFn = result.current + rerender() + const secondThrottledFn = result.current + + expect(firstThrottledFn).toBe(secondThrottledFn) + }) + + it('should handle rapid successive calls efficiently', () => { + const callback = vi.fn() + const { result } = renderHook(() => useThrottledFn({ callbackToThrottle: callback, delay: 100 })) + + act(() => { + // Simulate rapid calls + for (let i = 0; i < 1000; i++) { + result.current(i) + } + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(0) // First call's argument - const { result } = renderHook(() => useThrottledFn(obj.callback, 500)) - result.current.call(obj) + act(() => { + vi.advanceTimersByTime(100) + result.current(1001) + }) - expect(res).toBe(obj) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 1001) + }) }) }) diff --git a/src/lib/use-throttled-fn/index.tsx b/src/lib/use-throttled-fn/index.tsx index 591c9f6..9f12747 100644 --- a/src/lib/use-throttled-fn/index.tsx +++ b/src/lib/use-throttled-fn/index.tsx @@ -1,6 +1,5 @@ 'use client' import React, { useRef } from 'react' -import useSyncedRef from '../use-synced-ref' const DEFAULT_DELAY = 300 @@ -11,30 +10,50 @@ const DEFAULT_DELAY = 300 * @see Docs https://classic-react-hooks.vercel.app/hooks/use-throttled-fn.html * */ -export default function useThrottledFn any>(cb: T, delay = DEFAULT_DELAY) { - const paramsRef = useSyncedRef({ - cb, +export default function useThrottledFn any>({ + callbackToThrottle, + delay, +}: { + callbackToThrottle: T + delay?: number +}) { + const paramsRef = useRef({ + callbackToThrottle, delay, }) - const throttledCb = useRef(throttledFnWrapper(paramsRef.current.cb, paramsRef.current.delay)) + // tracking props with immutable object + paramsRef.current.delay = delay + paramsRef.current.callbackToThrottle = callbackToThrottle + + // so can access the updated props inside debouncedFnWrapper function + const throttledCb = useRef(throttledFnWrapper(paramsRef.current)) + return throttledCb.current } /** * @description - * A wrapper function which is used internally in `useThrttledFn` hook. + * A wrapper function which is used internally in `useThrottledFn` hook. */ -export function throttledFnWrapper any>(cb: T, delay = DEFAULT_DELAY) { +export function throttledFnWrapper any>(props: { + callbackToThrottle: T + delay?: number +}) { let lastExecutionTime = 0 - return function (...args: Parameters) { + return function (...args: Parameters) { const currentTime = Date.now() - if (currentTime - lastExecutionTime >= delay) { - // @ts-ignore - cb.call(this, ...args) - lastExecutionTime = currentTime + if (currentTime - lastExecutionTime >= (props.delay ?? DEFAULT_DELAY)) { + try { + // @ts-expect-error -> making "this" as "any" type working + props.callbackToThrottle.call(this, ...args) + } catch (err) { + throw err + } finally { + lastExecutionTime = currentTime + } } } } From 6a468ed2ed2e5505aedf61e79f4e24273d771d59 Mon Sep 17 00:00:00 2001 From: Ashish-simpleCoder Date: Sat, 7 Jun 2025 20:57:55 +0530 Subject: [PATCH 10/81] canary: rewrite docs and hooks --- .github/workflows/canary-publish.yml | 32 + README.md | 19 +- apps/doc/getting-started/overview.md | 12 +- apps/doc/hooks/use-counter.md | 59 +- apps/doc/hooks/use-debounced-fn.md | 96 +- apps/doc/hooks/use-event-listener.md | 109 +- apps/doc/hooks/use-intersection-observer.md | 246 ++- apps/doc/hooks/use-interval-effect.md | 52 +- apps/doc/hooks/use-is-online.md | 24 +- apps/doc/hooks/use-local-storage.md | 137 +- apps/doc/hooks/use-on-mount-effect.md | 30 +- apps/doc/hooks/use-outside-click.md | 138 +- apps/doc/hooks/use-synced-effect.md | 74 +- apps/doc/hooks/use-synced-ref.md | 86 +- apps/doc/hooks/use-throttled-fn.md | 90 +- apps/doc/hooks/use-timeout-effect.md | 51 +- apps/doc/hooks/use-window-resize.md | 127 +- apps/doc/index.md | 14 +- apps/doc/pnpm-lock.yaml | 1991 ++++++++++++------- src/index.tsx | 12 +- src/lib/use-counter/index.test.tsx | 2 +- src/lib/use-counter/index.tsx | 12 +- src/lib/use-interval-effect/index.test.tsx | 10 +- src/lib/use-interval-effect/index.tsx | 8 +- src/lib/use-is-online/index.tsx | 92 +- src/lib/use-timeout-effect/index.test.tsx | 257 ++- src/lib/use-timeout-effect/index.tsx | 8 +- vitest.config.mts | 1 + 28 files changed, 2692 insertions(+), 1097 deletions(-) create mode 100644 .github/workflows/canary-publish.yml diff --git a/.github/workflows/canary-publish.yml b/.github/workflows/canary-publish.yml new file mode 100644 index 0000000..232da07 --- /dev/null +++ b/.github/workflows/canary-publish.yml @@ -0,0 +1,32 @@ +name: Publish +on: + push: + branches: + - canary + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + canary: + runs-on: 'ubuntu-latest' + permissions: write-all + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v4 + # with: + # version: 8 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + + - run: pnpm install --no-frozen-lockfile + + - name: Create Canary Release Pull Request or Canary Publish + id: changesets + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + publish: pnpm release diff --git a/README.md b/README.md index 33a04c2..443ed9d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🚀 classic-react-hooks -An awesome collection of `feature` packed custom hooks. +Essential Custom Hooks for React Developers
@@ -38,23 +38,6 @@ https://classic-react-hooks.vercel.app/ - tsup for build tooling -## ⚛️ Hook APIs - -- use-event-listener -- use-copy-to-clipboard -- use-local-storage -- use-outside-click -- use-debounced-fn -- use-throttled-hook -- use-is-online -- use-timeout-effect -- use-interval-effect -- use-synced-ref -- use-synced-effect -- use-on-mount-effect -- use-counter - - ## 🚀 Install in your project For npm users diff --git a/apps/doc/getting-started/overview.md b/apps/doc/getting-started/overview.md index 7904c90..ef104ea 100644 --- a/apps/doc/getting-started/overview.md +++ b/apps/doc/getting-started/overview.md @@ -2,8 +2,8 @@ ## What is **classic-react-hooks**? -- **`classic-react-hooks`** is collection of feature packed custom react hooks -- It helps you to write day-to-day code in a manner which is easy to write, declarative and maintainable. +- **`classic-react-hooks`** is a collection of feature-rich custom React hooks designed to simplify your daily development tasks. +- It promotes a declarative, easy-to-write, and maintainable coding style. ## Installation @@ -29,7 +29,7 @@ bun add classic-react-hooks ## Features -- Comprehensive hooks collection -- Typesafe (Built in Typescript) -- Comes with treeshaking -- Small, Minimal and Easy to use +- 📚 Comprehensive collection of custom hooks +- 🛡️ Type-safe (built with TypeScript) +- 🌲 Tree-shakable for optimized bundle size +- ⚡ Lightweight, minimal, and easy to integrate diff --git a/apps/doc/hooks/use-counter.md b/apps/doc/hooks/use-counter.md index 409d779..599a28c 100644 --- a/apps/doc/hooks/use-counter.md +++ b/apps/doc/hooks/use-counter.md @@ -4,23 +4,41 @@ outline: deep # use-counter -- A simple hook for managing counter. +- A Hook for Fun +- A type-safe React hook for managing counter state with customizable step values and dynamic property naming + +#### Features + +- Find out yourself buddy ### Parameters -| Parameter | Type | Required | Default Value | Description | -| ------------ | :----: | :------: | :-----------: | --------------------------------------------------------------------------- | -| key | string | ❌ | "" | Based on the key, it generates `type-safe` object with `prefixed` proprety. | -| initialValue | number | ❌ | 0 | Initial value of the counter. | +| Parameter | Type | Required | Default Value | Description | +| ------------ | ------ | :------: | :-----------: | ------------------------------------------------------------------------- | +| key | string | ❌ | "" | Prefix for generated property names. Creates type-safe object properties. | +| props | object | ❌ | undefined | Configuration object containing `initialValue` and `stepper`. | +| initialValue | number | ❌ | 0 | Initial value for the counter. | +| stepper | number | ❌ | 1 | Amount to increment/decrement by on each operation. | ### Returns -- It returns an object. -- `counter` : number -- `incrementCounter` : () => void -- `decrementCounter` : () => void +Returns a type-safe object with dynamically named properties: + +#### Without key (default): + +- `counter:` number - Current counter value +- `incrementCounter:` () => void - Function to increment counter +- `decrementCounter:` () => void - Function to decrement counter + +#### With key (e.g., "user"): -### Usage +- `userCounter:` number - Current counter value +- `incrementUserCounter:` () => void - Function to increment counter +- `decrementUserCounter:` () => void - Function to decrement counter + +### Usage Examples + +#### Basic Counter ```ts import { useCounter } from 'classic-react-hooks' @@ -42,3 +60,24 @@ export default function YourComponent() { ) } ``` + +#### Named Counter with Custom Step + +```ts +import { useCounter } from 'classic-react-hooks' + +export default function UserScoreCounter() { + const { userCounter, incrementUserCounter, decrementUserCounter } = useCounter('user', { + initialValue: 10, + stepper: 5, + }) + + return ( +
+

User Score: {userCounter}

+ + +
+ ) +} +``` diff --git a/apps/doc/hooks/use-debounced-fn.md b/apps/doc/hooks/use-debounced-fn.md index d225959..d2e1042 100644 --- a/apps/doc/hooks/use-debounced-fn.md +++ b/apps/doc/hooks/use-debounced-fn.md @@ -4,38 +4,100 @@ outline: deep # use-debouced-fn -- A hook which returns a debounced function. +- A React hook that returns a debounced version of any function, delaying its execution until after a specified delay has passed since the last time it was invoked. +- Perfect for optimizing performance in scenarios like search inputs, API calls, or resize handlers. + +### Features + +- **Debouncing Functionality:** The primary feature is delaying function execution until after a specified period of inactivity. If the function is called again before the delay expires, the previous call is cancelled and the timer resets. +- **Configurable Delay:** You can specify a custom delay period, with a sensible default of 300ms if none is provided. +- **Dynamic Props Updates:** The hook properly handles updates to both the callback function and delay value during re-renders without losing the debouncing behavior. +- **Performance optimized:** Prevents excessive function calls +- **Auto cleanup:** Automatically clears timers on component unmount and on delay prop change ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------: | :------: | :-----------: | ----------------------------------------------------------- | -| cb | Function | ✅ | - | A callback which is to be debounced. | -| delay | number | ❌ | 300 | A delay in milliseconds after that the callback gets fired. | +| Parameter | Type | Required | Default Value | Description | +| ---------------- | :------: | :------: | :-----------: | ----------------------------------------------------- | +| callbackToBounce | Function | ✅ | - | The function to be debounced | +| delay | number | ❌ | 300 | Delay in milliseconds before the function is executed | ### Returns -- It returns a function which is debouced version of passed callback. +- Returns a debounced version of the provided function that will only execute after the specified delay has passed since the last invocation. -### Usage +### Usage Examples + +#### Basic debouncing ```ts -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useDebouncedFn } from 'classic-react-hooks' -export default function YourComponent() { - const [debouncedInput, setDebouncedInput] = useState('') - const updateInput = useDebouncedFn((e) => { - setDebouncedInput(e.target.value) - }, 300) +export default function SearchInput() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + + const debouncedSearch = useDebouncedFn({ + callbackToBounce: async (searchTerm: string) => { + if (searchTerm.trim()) { + const response = await fetch(`https://dummyjson.com/users/search?q=${searchTerm}`) + const data = await response.json() + setResults(data.results) + } + }, + delay: 500, + }) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setQuery(value) + debouncedSearch(value) + } + + useEffect(() => { + ;(async function () { + const response = await fetch(`https://dummyjson.com/users`) + const data = await response.json() + setResults(data.results) + })() + }, []) return (
- -

- value - {debouncedInput} -

+ +
+ {results.map((result) => ( +
{result.name}
+ ))} +
) } ``` + +### Common Use Cases + +- Delay API calls until user stops typing +- Validate fields after user pauses input +- Prevent excessive API calls + +### Alternative: Non-React Usage + +For use outside of React components, use the standalone wrapper: + +```ts +import { debouncedFnWrapper } from 'classic-react-hooks' + +const { fn: debouncedLog, cleanup } = debouncedFnWrapper({ + callbackToBounce: (message: string) => console.log(message), + delay: 1000, +}) + +// Use the debounced function +debouncedLog('Hello') +debouncedLog('World') // Only 'World' will be logged after 1 second + +// Clean up when done +cleanup() +``` diff --git a/apps/doc/hooks/use-event-listener.md b/apps/doc/hooks/use-event-listener.md index d52c965..569654a 100644 --- a/apps/doc/hooks/use-event-listener.md +++ b/apps/doc/hooks/use-event-listener.md @@ -4,45 +4,114 @@ outline: deep # use-event-listener -- A hook which handles dom events in efficient and declarative manner. +A React hook that provides a declarative way to add DOM event listeners with automatic cleanup. -### Parameters +### Features -| Parameter | Type | Required | Default Value | Description | -| --------- | :-----------------------: | :------: | :-----------: | ------------------------------ | -| target | [Target](#parametertype) | ✅ | - | Reference of the html element | -| event | string | ✅ | - | Event name | -| handler | [Handler](#parametertype) | ❌ | undefined | Callback for the event | -| options | [Options](#parametertype) | ❌ | undefined | For managing Event Propagation | +- **Auto cleanup:** Events are automatically removed on unmount or dependency changes +- **Reactive:** The hook re-evaluates and potentially re-attaches listeners when any dependency changes (target, event, options) +- **Conditional events:** Built-in support for conditionally enabling/disabling event +- **Performance:** Event listeners are only attached when all conditions are met: target exists, handler is provided, and `shouldInjectEvent` is true +- **Standard options:** Full support for all `AddEventListenerOptions` (capture, once, passive, signal) -### Types +### Parameters ---- +| Parameter | Type | Required | Default Value | Description | +| --------- | :-----------------: | :------: | :-----------: | ------------------------------------------------ | +| target | [EvTarget](#types) | ✅ | - | Function that returns the target element or null | +| event | string | ✅ | - | Event name (e.g., 'click', 'keydown', 'resize') | +| handler | [EvHandler](#types) | ❌ | undefined | Event handler callback function | +| options | [EvOptions](#types) | ❌ | undefined | Event listener options and feature flags | +| | -#### ParameterType +#### Types ```ts -type Target = null | EventTarget | (() => EventTarget | null) -type Options = boolean | (AddEventListenerOptions & { shouldInjectEvent?: boolean | any }) -type Handler = (event: Event) => void +export type EvTarget = () => EventTarget | null +export type EvHandler = (event: Event) => void + +export interface EvOptions extends AddEventListenerOptions { + // Standard AddEventListenerOptions: + // capture?: boolean + // once?: boolean + // passive?: boolean + // signal?: AbortSignal + + // Custom option: + shouldInjectEvent?: boolean | any // Controls whether the event should be attached +} ``` -### Usage +### Usage Examples + +#### Basic Click Handler ```ts import { useRef } from 'react' import { useEventListener } from 'classic-react-hooks' -export default function YourComponent() { - const ref = useRef() - useEventListener(ref, 'click', (e) => { - console.log(e) +export default function ClickExample() { + const buttonRef = useRef(null) + + useEventListener({ + target: () => buttonRef.current, + event: 'click', + handler: (e) => { + console.log('Button clicked!', e) + }, + }) + + return +} +``` + +#### Window Events + +```ts +import { useEventListener } from 'classic-react-hooks' + +export default function WindowExample() { + useEventListener({ + target: () => window, + event: 'resize', + handler: (e) => { + console.log('Window resized:', window.innerWidth, window.innerHeight) + }, + }) + + return
Resize the window and check console
+} +``` + +#### Conditional Event Listening + +```ts +import { useState } from 'react' +import { useEventListener } from 'classic-react-hooks' + +export default function ConditionalExample() { + const [isListening, setIsListening] = useState(true) + + useEventListener({ + target: () => document, + event: 'keydown', + handler: (e) => { + console.log('Key pressed:', e.key) + }, + options: { + shouldInjectEvent: isListening, // Only listen when enabled + }, }) return (
- + +

Press any key (when listening is enabled)

) } ``` + +### Common Use Cases + +- Adding dom events (e.g 'click', 'keydown', 'resize') diff --git a/apps/doc/hooks/use-intersection-observer.md b/apps/doc/hooks/use-intersection-observer.md index 5636ece..de0e1e9 100644 --- a/apps/doc/hooks/use-intersection-observer.md +++ b/apps/doc/hooks/use-intersection-observer.md @@ -4,77 +4,233 @@ outline: deep # use-intersection-observer -- A hook which provides a way for listening to the Intersection Observer event for given target. -- It returns an array of boolean values which represents whether the targets are intersecting the screen or not. +A React hook that provides a declarative way to observe multiple elements with the Intersection Observer API, returning their visibility states with advanced triggering options. + +### Features + +- **Multiple targets:** Observe multiple elements simultaneously +- **Flexible triggering:** Control whether elements trigger once or continuously +- **Per-element configuration:** Different trigger behavior for each element +- **Auto cleanup:** Observer is automatically disconnected on unmount +- **Fallback support:** Graceful degradation when IntersectionObserver is not available +- **Callback support:** Execute custom logic when elements become visible +- **Performance:** Elements with `only_trigger_once: true` are automatically unobserved after first intersection +- **Per-element control:** Use `only_trigger_once` as an array to control trigger behavior per element ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------------------------: | :------: | :-----------: | ------------------------------------------------------------- | -| targets | [Target[]](#parametertype) | ✅ | - | Array of targets which contains reference of the html element | -| options | [Options](#parametertype) | ❌ | {} | Options to pass as feature flag | +| Parameter | Type | Required | Default | Description | +| -------------- | :------------------------------------: | :------: | :---------------------------: | ------------------------------------------------------- | +| targets | [IntersectionObserverTarget[]](#types) | ✅ | - | Array of functions that return target elements | +| options | [IntersectionOptions](#types) | ❌ | "{ only_trigger_once: true }" | Intersection observer options and custom configurations | +| onIntersection | (target: Element) => void | ❌ | undefined | Callback executed when an element becomes visible | +| | -### Types +#### Types ---- +```ts +export type IntersectionObserverTarget = () => Element | null +export type IsTargetIntersecting = boolean + +export interface IntersectionOptions extends IntersectionObserverInit { + // Standard IntersectionObserverInit: + // root?: Element | Document | null + // rootMargin?: string + // threshold?: number | number[] + + // Custom options + only_trigger_once?: boolean | boolean[] // Control per-element trigger behavior +} +``` + +### Return Value + +Returns an array of boolean values (`Array`) where each boolean represents whether the corresponding target element is currently intersecting (visible) or not. -#### ParameterType +### Usage Examples + +#### Basic Usage - Multiple Elements ```ts -type Target = HTMLElement | RefObject | (() => HTMLElement | null) | null -type Options = { - mode?: 'lazy' | 'virtualized' -} & IntersectionObserverInit +import { useRef } from 'react' +import { useInterSectionObserver } from 'classic-react-hooks' + +export default function BasicIntersection() { + const box1Ref = useRef(null) + const box2Ref = useRef(null) + const box3Ref = useRef(null) + + const [isBox1Visible, isBox2Visible, isBox3Visible] = useInterSectionObserver({ + targets: [() => box1Ref.current, () => box2Ref.current, () => box3Ref.current], + }) + + return ( +
+
Scroll down to see boxes
+ +
+ Box 1 - {isBox1Visible ? 'Visible' : 'Hidden'} +
+ +
+ Box 2 - {isBox2Visible ? 'Visible' : 'Hidden'} +
+ +
+ Box 3 - {isBox3Visible ? 'Visible' : 'Hidden'} +
+
+ ) +} ``` -### Usage +#### Per-Element Trigger Control ```ts -import { ElementRef, useRef } from 'react' +import { useRef } from 'react' import { useInterSectionObserver } from 'classic-react-hooks' -export default function Intersection() { - const purpleBoxRef = useRef>(null) - const greenBoxRef = useRef>(null) - const [isPurpleVisible, isGreenVisible] = useInterSectionObserver([purpleBoxRef, greenBoxRef], { - threshold: 0, - root: null, - rootMargin: '-150px', - mode: 'virtualized', +export default function PerElementTrigger() { + const onceRef = useRef(null) + const continuousRef = useRef(null) + const alsoOnceRef = useRef(null) + + const [isOnceVisible, isContinuousVisible, isAlsoOnceVisible] = useInterSectionObserver({ + targets: [() => onceRef.current, () => continuousRef.current, () => alsoOnceRef.current], + options: { + // Per-element trigger control: [once, continuous, once] + only_trigger_once: [true, false, true], + threshold: 0.5, + }, }) return ( - <> -

- Scroll to the very bottom of the page -

- -

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Modi quae illum rem quod recusandae a tempora - officia natus quos dignissimos, eum beatae ea! Consectetur nemo assumenda eligendi optio voluptatum fuga. -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Modi quae illum rem quod recusandae a tempora - officia natus quos dignissimos, eum beatae ea! Consectetur nemo assumenda eligendi optio voluptatum fuga. -

+
+
+ Scroll to see different behaviors +
- purple + Triggers Once - {isOnceVisible ? 'Triggered!' : 'Waiting...'}
+ +
Scroll past and back up
+
- green + Continuous - {isContinuousVisible ? 'Visible' : 'Hidden'}
- + +
Scroll past and back up
+ +
+ Also Triggers Once - {isAlsoOnceVisible ? 'Triggered!' : 'Waiting...'} +
+
) } ``` + +#### With Intersection Callback + +```ts +import { useRef, useState } from 'react' +import { useInterSectionObserver } from 'classic-react-hooks' + +export default function WithCallback() { + const [lastIntersected, setLastIntersected] = useState('') + const box1Ref = useRef(null) + const box2Ref = useRef(null) + + const [isBox1Visible, isBox2Visible] = useInterSectionObserver({ + targets: [() => box1Ref.current, () => box2Ref.current], + options: { + threshold: 0.8, + only_trigger_once: false, + }, + onIntersection: (target) => { + // Get the element's text content to identify which one intersected + const elementText = target.textContent || 'Unknown' + setLastIntersected(`${elementText} became visible at ${new Date().toLocaleTimeString()}`) + + // You could also trigger animations, analytics, lazy loading, etc. + console.log('Element intersected:', target) + }, + }) + + return ( +
+
+
Last intersected:
+
{lastIntersected || 'None yet'}
+
+ +
+ Scroll down to trigger intersections +
+ +
+ Box 1 - {isBox1Visible ? 'Visible' : 'Hidden'} +
+ +
+ Box 2 - {isBox2Visible ? 'Visible' : 'Hidden'} +
+ +
Bottom spacer
+
+ ) +} +``` + +### Important Notes + +- If IntersectionObserver is not supported, a warning is logged and the hook gracefully degrades. + +### Common Use Cases + +- Lazy loading images or content +- Triggering animations on scroll +- Analytics tracking for element visibility +- Infinite scrolling implementation +- Performance optimization by conditionally rendering components +- Scroll-triggered navigation highlighting diff --git a/apps/doc/hooks/use-interval-effect.md b/apps/doc/hooks/use-interval-effect.md index d572d7a..5c3ecb6 100644 --- a/apps/doc/hooks/use-interval-effect.md +++ b/apps/doc/hooks/use-interval-effect.md @@ -4,39 +4,59 @@ outline: deep # use-interval-effect -- A hooks which fires the provided callback every time when the given delay is passed, just like the `setInterval`. +A React hook that executes a callback function at regular intervals, similar to `setInterval` but with additional control methods for clearing and restarting the timer. + +### Features + +- **Scheduled execution:** Executes a callback with a fixed time delay between each call +- **Flexible:** Provides methods to clear or restart the timer +- **Automatic Cleanup:** Automatically cleans up timer on component unmount +- **Syncronization:** Syncs with the latest callback and timeout values ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------: | :------: | :-----------: | -------------------------------------------------------------------- | -| cb | Function | ✅ | - | Callback gets fired after every given amount of interval is passed . | -| interval | number | ❌ | 100 | Interval value after which the callback is fired. | +| Parameter | Type | Required | Default Value | Description | +| --------- | :------: | :------: | :-----------: | ------------------------------------------------ | +| handler | Function | ✅ | - | Callback function executed at each interval | +| interval | number | ❌ | 100 | Time in milliseconds between callback executions | ### Returns -- It returns an object. -- `clearTimer` : () => void -- `restartTimer` : () => void +- Returns an object with control methods: + + - `clearTimer` : `() => void` Cancels the current interval, preventing the handler from executing + - `restartTimer` : `() => void` Clears the current timer and starts a new one. Optionally accepts a new interval value ### Usage +#### Basic example + ```ts import { useState } from 'react' import { useIntervalEffect } from 'classic-react-hooks' -export default function YourComponent() { - const [counter, setCounter] = useState(0) - const { clearTimer, restartTimer } = useIntervalEffect(() => { - setCounter((c) => c + 1) - }, 1000) +export default function Counter() { + const [count, setCount] = useState(0) + + const { clearTimer, restartTimer } = useIntervalEffect({ + handler: () => setCount((prev) => prev + 1), + interval: 1000, // 1 second + }) return (
-

{counter}

- - +

Count: {count}

+ + + +
) } ``` + +### Common Use Cases + +- Countdown timers +- Real-time updates (clocks, progress bars) +- Polling APIs at regular intervals diff --git a/apps/doc/hooks/use-is-online.md b/apps/doc/hooks/use-is-online.md index b42297c..d8e2a4b 100644 --- a/apps/doc/hooks/use-is-online.md +++ b/apps/doc/hooks/use-is-online.md @@ -4,20 +4,34 @@ outline: deep # use-is-online -- A simple hook for getting the network connection state. +A React hook that provides real-time network connection status using the browser's `navigator.onLine` API. + +### Features + +- **Real-time updates:** Real-time network status updates +- **SSR safe:** SSR-safe with proper hydration handling +- **Lightweight:** Lightweight with no external dependencies +- **Core hook:** Built on React's useSyncExternalStore for optimal performance ### Returns -- `connectionState` : boolean +- `isOnline (boolean):` Current network connection state + + - `true` when the browser is online + - `false` when the browser is offline -### Usage +### Usage Examples + +#### Basic Network query ```ts import { useIsOnline } from 'classic-react-hooks' -export default function YourComponent() { +function NetworkStatus() { const isOnline = useIsOnline() - return
{isOnline ? 'online' : 'offline'}
+ return
Connection: {isOnline ? '🟢 Online' : '🔴 Offline'}
} ``` + +### Important Notes diff --git a/apps/doc/hooks/use-local-storage.md b/apps/doc/hooks/use-local-storage.md index 4af8e56..0def9b3 100644 --- a/apps/doc/hooks/use-local-storage.md +++ b/apps/doc/hooks/use-local-storage.md @@ -4,49 +4,132 @@ outline: deep # use-local-storage -- A hook for managing the states with `local-storage` -- It automatically updates the state in `local-storage` -- It is `useState` with local storage power. +- A React hook that synchronizes state with localStorage, providing `persistent` state management across browser `sessions`. +- Works exactly like `useState` but automatically persists data to localStorage. + +### Features + +- **Automatic sync:** Automatic localStorage synchronization +- **Persistence updates:** Seamless state updates with persistence +- **Error handling:** Built-in error handling and fallbacks +- **Compatible API:** useState-compatible API +- **Automatic parsing:** JSON serialization/deserialization ### Parameters -| Parameter | Type | Required | Default Value | Description | -| ------------ | :----: | :------: | :-----------: | ---------------------------------------------------- | -| key | string | ✅ | - | key for getting an item from local-storage | -| defaultValue | any | ❌ | - | A initial value when item is not found local-storage | +| Parameter | Type | Required | Default Value | Description | +| ------------ | :----: | :------: | :-----------: | ----------------------------------------- | +| key | string | ✅ | - | Unique key for localStorage item | +| defaultValue | any | ❌ | undefined | Initial value when no stored value exists | ### Returns -- It returns an array of `state` and setter function `setState`. -- `state` : It's type get inferred by `defaultValue` parameter. -- `setState` : A function just like the setter function from `useState`. +- It returns an array containing: + + 1. `state:` Current value (type inferred from defaultValue) + 2. `setState:` State setter function (identical to useState setter) + +### Usage Examples -### Usage +#### Basic User Preferences ```ts import { useLocalStorage } from 'classic-react-hooks' -export default function YourComponent() { - const [user_details, setUserDetails] = useLocalStorage('user_details', { - name: '', - }) +function UserPreferences() { + const [theme, setTheme] = useLocalStorage({ key: 'theme', defaultValue: 'light' }) + const [language, setLanguage] = useLocalStorage({ key: 'language', defaultValue: 'en' }) return (
+ + + +
+ ) +} +``` + +#### Complex Object State + +```ts +interface UserProfile { + name: string + email: string + preferences: { + notifications: boolean + newsletter: boolean + } +} + +function ProfileForm() { + const [profile, setProfile] = useLocalStorage({ + key: 'user-profile', + defaultValue: { + name: '', + email: '', + preferences: { + notifications: true, + newsletter: false, + }, + }, + }) + + const updateName = (name: string) => { + setProfile((prev) => ({ + ...prev, + name, + })) + } + + const toggleNotifications = () => { + setProfile((prev) => ({ + ...prev, + preferences: { + ...prev.preferences, + notifications: !prev.preferences.notifications, + }, + })) + } + + return ( +
+ updateName(e.target.value)} placeholder='Enter your name' /> + - setUserDetails((user) => { - user.name = e.target.value - return { - ...user, - } - }) - } - className='py-1 px-3 rounded-md bg-white dark:bg-gray-900' - placeholder='update name...' + type='email' + value={profile.email} + onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))} + placeholder='Enter your email' /> -
+ + + ) } ``` + +### Important Notes + +- **Automatic Serialization:** Data is automatically serialized to JSON when storing. +- **Synchronous Updates:** State updates are synchronous and immediately persisted. +- **No storage events:** Changes in one tab don't automatically sync to other tabs (consider storage events for that). +- **Fallback value:** Always provide default values for SSR fallback. + +### Common Use Cases + +- Theme preferences (dark/light mode) +- Form draft saving (auto-save functionality) +- Shopping cart persistence +- User settings and preferences +- Feature flags for application diff --git a/apps/doc/hooks/use-on-mount-effect.md b/apps/doc/hooks/use-on-mount-effect.md index c96d4ea..9b879f0 100644 --- a/apps/doc/hooks/use-on-mount-effect.md +++ b/apps/doc/hooks/use-on-mount-effect.md @@ -4,16 +4,19 @@ outline: deep # use-on-mount-effect -- A hooks that fires the given callback only once after the mount. -- It doesn't take any dependencies. +- A React hook that executes a callback function only once after the component mounts. This is a simplified wrapper around useEffect with an empty dependency array. + +- This hook is perfect for initialization logic that should run exactly once when a component first renders. ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------: | :------: | :-----------: | ------------------------------------- | -| cb | Function | ✅ | - | Callback to fire after initial mount. | +| Parameter | Type | Required | Default Value | Description | +| --------- | :------------------: | :------: | :-----------: | ----------------------------------------------------------------------------- | +| cb | React.EffectCallback | ✅ | - | Callback function to execute once after mount. Can return a cleanup function. | + +### Usage Examples -### Usage +#### Basic Usage - Initialization Logic ```ts import { useOnMountEffect } from 'classic-react-hooks' @@ -26,3 +29,18 @@ export default function YourComponent() { return
} ``` + +### Comparison with useEffect + +| Scenario | useEffect(cb, []) | useOnMountEffect(cb) | +| ------------------- | -------------------- | ------------------------- | +| Intent clarity | ❌ Less obvious | ✅ Crystal clear | +| Code brevity | ❌ More verbose | ✅ Cleaner | +| Dependency mistakes | ⚠️ Easy to forget [] | ✅ No dependencies needed | +| TypeScript support | ✅ Yes | ✅ Yes | +| Cleanup support | ✅ Yes | ✅ Yes | + +### Common Use Cases + +- Setting up initial state or configuration +- Any one-time setup that shouldn't repeat after component mount diff --git a/apps/doc/hooks/use-outside-click.md b/apps/doc/hooks/use-outside-click.md index 919e75c..d12ddae 100644 --- a/apps/doc/hooks/use-outside-click.md +++ b/apps/doc/hooks/use-outside-click.md @@ -4,56 +4,134 @@ outline: deep # use-outside-click -- A hook that fires the given callback when clicked outside anywhere of the given html element. +- A React hook that detects outside click for specified element and triggers the given callback. +- Perfect for implementing modals, dropdowns and other UI components that need to be closed when users click outside of them. -### Parameters +### Features -| Parameter | Type | Required | Default Value | Description | -| --------- | :-----------------------: | :------: | :-----------: | ----------------------------- | -| target | [Target](#parametertype) | ✅ | - | Reference of the html element | -| handler | [Handler](#parametertype) | ❌ | undefined | Callback for the event | -| options | [Options](#parametertype) | ❌ | undefined | Extra params | +- **Precise trigger:** Precise outside click detection +- **Performance:** Optimized with capture phase events +- **Underlying hook:** At its core, it uses `useEventListener` hook -### Types +### Parameters ---- +| Parameter | Type | Required | Default Value | Description | +| --------- | :-----------------: | :------: | :-----------: | ------------------------------------------------ | +| target | [EvTarget](#types) | ✅ | - | Function that returns the target element or null | +| handler | [EvHandler](#types) | ❌ | undefined | Callback executed on outside click | +| options | [EvOptions](#types) | ❌ | undefined | Event listener options and feature flags | -#### ParameterType +#### Types ```ts -type Target = null | EventTarget | (() => EventTarget | null) -type Options = { shouldInjectEvent?: boolean | any } -type Handler = (event: Event) => void +type EvTarget = () => EventTarget | null +type EvHandler = (event: DocumentEventMap['click']) => void +interface EvOptions extends AddEventListenerOptions { + // Standard AddEventListenerOptions: + // capture?: boolean + // once?: boolean + // passive?: boolean + // signal?: AbortSignal + + // Custom option: + shouldInjectEvent?: boolean | any // Controls whether the event should be attached +} ``` -### Usage +### Usage Examples + +#### Modal Component ```ts -import { useRef } from 'react' +import { useRef, useState } from 'react' import { useOutsideClick } from 'classic-react-hooks' -export default function YourComponent() { - const modalRef = useRef(null) - useOutsideClick( - modalRef, - (e) => { - console.log('clicked outside on modal. Target = ', e.target) +function Modal() { + const [isOpen, setIsOpen] = useState(false) + const modalRef = useRef(null) + + useOutsideClick({ + target: () => modalRef.current, + handler: () => setIsOpen(false), + }) + + if (!isOpen) { + return + } + + return ( +
+ +
+ ) +} +``` + +#### Conditional Outside Click + +```ts +function ConditionalOutsideClick() { + const [isModalOpen, setIsModalOpen] = useState(false) + const [isPinned, setIsPinned] = useState(false) + const modalRef = useRef(null) + + useOutsideClick({ + target: () => modalRef.current, + handler: () => { + // Only close if not pinned + if (!isPinned) { + setIsModalOpen(false) + } }, - { shouldInjectEvent: true } + }) + + return ( +
+ {isModalOpen && ( +
+ +

Modal content

+
+ )} +
) +} +``` + +#### Dynamic Target + +```ts +function DynamicTarget() { + const [activeElement, setActiveElement] = useState(null) + + useOutsideClick({ + target: () => activeElement, + handler: () => { + console.log('Clicked outside active element') + setActiveElement(null) + }, + }) + + const makeActive = (element: HTMLElement) => { + setActiveElement(element) + } return (
-
- This is modal +
makeActive(e.currentTarget)} className='p-5 bg-gray-100 m-2.5'> + Click to make active
) } ``` + +### Common Use Cases + +- Modal dialogs - Close when clicking backdrop +- Dropdown menus - Hide when clicking elsewhere +- Context menus - Dismiss on outside click diff --git a/apps/doc/hooks/use-synced-effect.md b/apps/doc/hooks/use-synced-effect.md index 9d2402c..11d85d0 100644 --- a/apps/doc/hooks/use-synced-effect.md +++ b/apps/doc/hooks/use-synced-effect.md @@ -4,17 +4,26 @@ outline: deep # use-synced-effect -- A hooks that fires the given callback for given dependencies when they get change. -- It works exacatly like `useEffect`. But callback doesn't get fired on initial mount. +- A React hook that executes a callback when dependencies change, similar to `useEffect`, but skips execution on the initial mount. +- This is particularly useful when you want to respond to state changes without triggering side effects during the component's first render. + +### Features + +- **Skip initial mount:** Skipping the callback on initial mount +- **Reactive:** Running the callback only when dependencies actually change +- **React StrictMode:** Handling React StrictMode double execution correctly +- **Flexible:** Supporting cleanup functions just like useEffect ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------: | :------: | :-----------: | ----------------------------------------------- | -| cb | Function | ✅ | - | Callback to fire when dependencies get changed. | -| deps | Array | ❌ | [] | Dependencies. | +| Parameter | Type | Required | Default Value | Description | +| --------- | :------------------: | :------: | :-----------: | ------------------------------------------------------------------------------------ | +| cb | React.EffectCallback | ✅ | - | Callback function to execute when dependencies change. Can return a cleanup function | +| deps | React.DependencyList | ❌ | [] | Array of dependencies to watch for changes | + +### Usage Examples -### Usage +#### Basic Usage - Responding to State Changes ```ts import { useState } from 'react' @@ -34,3 +43,54 @@ export default function YourComponent() { ) } ``` + +#### With Cleanup Function + +```ts +import { useState } from 'react' +import { useSyncedEffect } from 'classic-react-hooks' + +function SearchComponent() { + const [query, setQuery] = useState('') + + useSyncedEffect(() => { + // Only search when query actually changes, not on initial empty string + if (query) { + const controller = new AbortController() + + fetch(`/api/search?q=${query}`, { + signal: controller.signal, + }) + .then((response) => response.json()) + .then((data) => { + // Handle search results + }) + + // Cleanup function to cancel the request + return () => { + controller.abort() + } + } + }, [query]) + + return setQuery(e.target.value)} placeholder='Search...' /> +} +``` + +### Comparison with useEffect + +| Scenario | useEffect | useSyncedEffect | +| ------------------- | ---------------- | -------------------- | +| Initial mount | ✅ Runs | ❌ Skips | +| Dependency changes | ✅ Runs | ✅ Runs | +| Cleanup support | ✅ Yes | ✅ Yes | +| StrictMode handling | ⚠️ May run twice | ✅ Handles correctly | + +### Important notes + +- Empty dependency array [] means the effect will never run (since there are no dependencies to change) +- No dependency array means the effect will never run (same as array []) + +### Common Use Cases + +- Use everywhere just like `useEffect` diff --git a/apps/doc/hooks/use-synced-ref.md b/apps/doc/hooks/use-synced-ref.md index 6dd2059..c61089e 100644 --- a/apps/doc/hooks/use-synced-ref.md +++ b/apps/doc/hooks/use-synced-ref.md @@ -4,34 +4,100 @@ outline: deep # use-synced-ref -- A replacement for `useRef` hook, which automatically syncs-up with the given state. -- No need to manually update the ref. +- A React hook that creates a ref that automatically stays in sync with the provided value. +- This eliminates the need to manually update refs and helps avoid stale closure issues in callbacks and effects. + +### Features + +- **Reactive:** Automatic synchronization with any value +- **Prevent State Closure:**Prevents stale closure problems +- **No Re-render:**Zero re-renders - purely ref-based ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :--: | :------: | :-----------: | --------------------------- | -| value | any | ✅ | - | Value to be tracked in ref. | +| Parameter | Type | Required | Default Value | Description | +| --------- | :--: | :------: | :-----------: | ------------------------------------------------- | +| value | any | ✅ | - | Any value to be tracked and kept in sync with ref | ### Returns -- It returns the ref of given state. +- Returns a `React.MutableRefObject` that always contains the latest value of the provided state. ### Usage +#### Basic Example + ```ts import { useState } from 'react' import { useSyncedRef } from 'classic-react-hooks' -export default function YourComponent() { - const [counter, setCounter] = useState(0) +export default function Counter() { + const [count, setCount] = useState(0) + const countRef = useSyncedRef(count) - const counterRef = useSyncedRef(counter) + const handleAsyncOperation = () => { + setTimeout(() => { + // countRef.current always has the latest value + console.log('Current count:', countRef.current) + alert(`Count is now: ${countRef.current}`) + }, 2000) + } return (
- +

Count: {count}

+ +
) } ``` + +### Problem It Solves + +#### The Stale Closure Problem + +In React, when you use hooks like useEffect, useCallback, or setTimeout with dependency arrays, you often encounter stale closure issues: + +```ts +// ❌ Problematic code +function ProblematicComponent() { + const [count, setCount] = useState(0) + + useEffect(() => { + const interval = setInterval(() => { + console.log(count) // Always logs 0 (stale closure) + }, 1000) + return () => clearInterval(interval) + }, []) // Empty deps = stale closure + + // vs + + useEffect(() => { + const interval = setInterval(() => { + console.log(count) // Works but recreates interval on every count change + }, 1000) + return () => clearInterval(interval) + }, [count]) // Including count fixes staleness but causes recreation +} + +// ✅ Solution with useSyncedRef +function SolvedComponent() { + const [count, setCount] = useState(0) + const countRef = useSyncedRef(count) + + useEffect(() => { + const interval = setInterval(() => { + console.log(countRef.current) // Always logs latest count + }, 1000) + return () => clearInterval(interval) + }, []) // Empty deps = no recreation, no staleness! +} +``` + +### Common Use Cases + +- Accessing latest state in intervals/timeouts +- Event handlers that need current state +- Custom hooks with complex state dependencies +- Preventing effect recreations while avoiding stale closures diff --git a/apps/doc/hooks/use-throttled-fn.md b/apps/doc/hooks/use-throttled-fn.md index acca427..8e792bd 100644 --- a/apps/doc/hooks/use-throttled-fn.md +++ b/apps/doc/hooks/use-throttled-fn.md @@ -4,38 +4,96 @@ outline: deep # use-throttled-fn -- A hook which returns a throttled function. +A React hook that returns a throttled version of a callback function. + +- Throttling ensures that the function is called at most once per specified time interval, regardless of how many times it's invoked. +- This is particularly useful for performance optimization in scenarios like handling rapid user input, scroll events, or API calls. + +### Features + +- **Throttling Functionality:** Limits function execution to at most once per specified time period +- **Configurable Delay:** Accepts a custom delay period with a default of 300ms +- **Dynamic Props Updates:** The hook properly handles updates to both the callback function and delay value during re-renders without losing the debouncing behavior +- **Performance optimized:** Prevents excessive function calls ### Parameters -| Parameter | Type | Required | Default Value | Description | -| --------- | :------: | :------: | :-----------: | ----------------------------------------------------------- | -| cb | Function | ✅ | - | A callback which is to be throttled. | -| delay | number | ❌ | 300 | A delay in milliseconds after that the callback gets fired. | +| Parameter | Type | Required | Default Value | Description | +| ------------------ | :------: | :------: | :-----------: | --------------------------------------------------------- | +| callbackToThrottle | Function | ✅ | - | The callback function that should be throttled | +| delay | number | ❌ | 300 | Delay in milliseconds between allowed function executions | ### Returns -- It returns a function which is throttled version of passed callback. +- Returns a throttled version of the provided callback function that maintains the same signature and behavior, but with throttling applied. + +### Usage Examples -### Usage +#### Basic API throttling ```ts import { useState } from 'react' import { useThrottledFn } from 'classic-react-hooks' -export default function YourComponent() { - const [throttledInput, setThrottledInput] = useState('') - const updateInput = useThrottledFn((e) => { - setThrottledInput(e.target.value) - }, 300) +export default function AutoSave() { + const [content, setContent] = useState('') + const [saving, setSaving] = useState(false) + + const saveContent = useThrottledFn({ + callbackToThrottle: async (text) => { + setSaving(true) + try { + await saveToAPI(text) + console.log('Content saved!') + } catch (error) { + console.error('Save failed:', error) + } finally { + setSaving(false) + } + }, + delay: 2000, // Auto-save every 2 seconds at most + }) + + const handleChange = (e) => { + const newContent = e.target.value + setContent(newContent) + saveContent(newContent) + } return (
- -

- value - {throttledInput} -

+