Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/hamo/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@
}
}
},
"overrides": [
{
"includes": ["**/use-scroll-trigger/debugger.tsx"],
"linter": {
"rules": {
"a11y": {
"noStaticElementInteractions": "off"
}
}
}
}
],
"javascript": {
"formatter": {
"quoteStyle": "single",
Expand Down
19 changes: 18 additions & 1 deletion packages/hamo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
"default": "./dist/hamo.cjs"
}
},
"./scroll-trigger/debugger": {
"import": {
"types": "./dist/use-scroll-trigger/debugger.d.ts",
"default": "./dist/use-scroll-trigger/debugger.mjs"
},
"require": {
"types": "./dist/use-scroll-trigger/debugger.d.cts",
"default": "./dist/use-scroll-trigger/debugger.cjs"
}
},
"./package.json": "./package.json"
},
"files": [
Expand Down Expand Up @@ -71,13 +81,20 @@
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"happy-dom": "^20.0.0",
"lenis": "^1.3.19",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsdown": "^0.21.4",
"typescript": "^5.4.5"
},
"peerDependencies": {
"react": ">=18.0.0"
"react": ">=18.0.0",
"lenis": ">=1.3.0"
},
"peerDependenciesMeta": {
"lenis": {
"optional": true
}
},
"overrides": {
"yaml": "^2.9.0"
Expand Down
9 changes: 9 additions & 0 deletions packages/hamo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,20 @@ export {
useDebouncedState,
useTimeout,
} from './use-debounce'
export { useEffectEvent } from './use-effect-event'
export { useIntersectionObserver } from './use-intersection-observer'
export { useLazyState } from './use-lazy-state'
export { useMediaQuery } from './use-media-query'
export { useObjectFit } from './use-object-fit'
export type { Rect } from './use-rect'
export { useRect } from './use-rect'
export { useResizeObserver } from './use-resize-observer'
export type { UseScrollTriggerOptions } from './use-scroll-trigger'
export { useScrollTrigger } from './use-scroll-trigger'
export type { Transform, TransformRef } from './use-transform'
export {
TransformContext,
TransformProvider,
useTransform,
} from './use-transform'
export { useWindowSize } from './use-window-size'
21 changes: 3 additions & 18 deletions packages/hamo/src/use-debounce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useRef,
useState,
} from 'react'
import { useEffectEvent } from '../use-effect-event'

export type DebouncedFunction<T extends (...args: any[]) => void> = ((
...args: Parameters<T>
Expand Down Expand Up @@ -44,28 +45,12 @@ function timeout(callback: (...args: any[]) => void, delay: number) {
return () => clearTimeout(timeout)
}

// A stable-identity callback that always invokes the latest `callback`. Named
// to avoid shadowing React's reserved `useEffectEvent` API — this is a plain
// ref-backed wrapper with none of that hook's call-site restrictions.
function useStableCallback<T extends (...args: any[]) => any>(callback: T): T {
const callbackRef = useRef(callback)
callbackRef.current = callback

const [memoizedCallback] = useState(
() =>
(...args: Parameters<T>) =>
callbackRef.current(...args)
)

return memoizedCallback as T
}

export function useDebouncedEffect(
_callback: () => void,
delay: number,
deps: DependencyList = []
) {
const callback = useStableCallback(_callback)
const callback = useEffectEvent(_callback)

useEffect(() => {
return timeout(() => callback(), delay)
Expand All @@ -77,7 +62,7 @@ export function useDebouncedCallback<T>(
delay: number,
deps: DependencyList = []
) {
const callback = useStableCallback(_callback)
const callback = useEffectEvent(_callback)

const timeoutRef = useRef<ReturnType<typeof timeout> | null>(null)

Expand Down
114 changes: 114 additions & 0 deletions packages/hamo/src/use-effect-event/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# useEffectEvent

A polyfill for React's experimental [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) hook. Returns a stable function reference that always calls the latest version of your callback, without needing to be listed in effect dependency arrays.

React's `useEffectEvent` is still experimental and not available in any stable release. This implementation provides the same core behavior for React 17+ projects.

## Usage

```jsx
import { useEffectEvent } from 'hamo'

function Chat({ url, onMessage }) {
const handleMessage = useEffectEvent(onMessage)

useEffect(() => {
const ws = new WebSocket(url)
ws.addEventListener('message', handleMessage)

return () => ws.close()
}, [url]) // handleMessage doesn't need to be in deps
}
```

## Parameters

- `callback`: The function to wrap. Can accept any arguments and return any value.

## Return Value

A stable function with the same signature as your callback. The identity never changes across renders, but calling it always invokes the latest `callback` from the most recent render.

## How It Works

The hook stores your callback in a ref (updated every render) and returns a memoized wrapper created once via lazy `useState`. The wrapper delegates to the ref on each call, so:

- The returned function has a **stable identity** (same reference every render)
- It always reads the **latest props and state** at call time
- It can safely be omitted from effect dependency arrays

## Differences from React's Experimental Version

| | React `useEffectEvent` | hamo `useEffectEvent` |
|---|---|---|
| Availability | Experimental, not in stable React | React 17+ |
| Identity | Intentionally unstable (changes every render) | Stable (same reference every render) |
| Callable from | Effects and other Effect Events only | Anywhere (effects, event handlers, callbacks) |
| Lint enforcement | ESLint plugin enforces constraints | No restrictions |

The stable identity in hamo's version is a practical trade-off — it makes the hook more versatile (usable in event handlers, passed as props) while still solving the core problem of reading latest values without re-triggering effects.

## Examples

### Interval with Latest State

```jsx
import { useEffect, useState } from 'react'
import { useEffectEvent } from 'hamo'

function Counter() {
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)

const onTick = useEffectEvent(() => {
setCount((c) => c + step) // always reads latest step
})

useEffect(() => {
const id = setInterval(onTick, 1000)
return () => clearInterval(id)
}, []) // no need to restart interval when step changes

return (
<div>
<p>{count}</p>
<input
type="number"
value={step}
onChange={(e) => setStep(Number(e.target.value))}
/>
</div>
)
}
```

### Effect Without Unnecessary Re-runs

```jsx
import { useEffect } from 'react'
import { useEffectEvent } from 'hamo'

function Logger({ data, onLog }) {
const log = useEffectEvent(onLog)

useEffect(() => {
log(data) // always calls latest onLog
}, [data]) // effect only re-runs when data changes, not when onLog changes
}
```

### Scroll Listener with Latest Callback

```jsx
import { useEffect } from 'react'
import { useEffectEvent } from 'hamo'

function useScroll(callback) {
const handler = useEffectEvent(callback)

useEffect(() => {
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, []) // listener attached once, always calls latest callback
}
```
16 changes: 16 additions & 0 deletions packages/hamo/src/use-effect-event/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useRef, useState } from 'react'

export function useEffectEvent<T extends (...args: any[]) => any>(
callback: T
): T {
const callbackRef = useRef(callback)
callbackRef.current = callback

const [memoizedCallback] = useState(
() =>
(...args: Parameters<T>) =>
callbackRef.current(...args)
)

return memoizedCallback as T
}
Loading
Loading