Skip to content

Conversation

@arzafran
Copy link
Member

@arzafran arzafran commented Jan 28, 2026

Summary

This PR modernizes hamo to leverage React 18+ features for better performance, concurrent rendering safety, and developer experience.

Key Changes

  • useSyncExternalStore adoption - Window size, media query, and rect hooks now use React 18's concurrent-safe subscription API
  • Selective window hooks - New useWindowWidth, useWindowHeight, useWindowDpr hooks that only re-render when their specific value changes
  • SSR safety - All hooks now gracefully handle server-side rendering
  • TypeScript improvements - Removed all @ts-ignore, proper generic inference for lazy mode
  • Shared utilities - Consolidated common patterns into packages/react/src/shared/
  • Migrated to bun - Replaced pnpm with bun for faster installs and builds
  • CI modernization - Updated GitHub Actions to Node 22 LTS
  • Playground overhaul - Interactive demos for all hooks with new branding

Breaking Changes

Change Before After
React version >=17.0.0 >=18.0.0
deps parameter Available on useResizeObserver, useIntersectionObserver, useLazyState, useRect Removed
useMediaQuery return boolean | undefined boolean

Migration for deps removal

If you relied on the deps array:

// Before
const [setRef, entry] = useResizeObserver({ callback: myFn }, [dep1, dep2])

// After - use callback ref pattern
const callback = useCallback(() => { /* uses dep1, dep2 */ }, [dep1, dep2])
const [setRef, entry] = useResizeObserver({ callback })

New Features

Selective Window Hooks

Avoid unnecessary re-renders by subscribing to only what you need:

import { useWindowWidth, useWindowHeight, useWindowDpr } from 'hamo'

function MyComponent() {
  // Only re-renders when width changes
  const width = useWindowWidth()
  
  return <div style={{ maxWidth: width > 768 ? '80%' : '100%' }} />
}

SSR Fallback for Media Queries

// Returns false on server, actual value on client
const isMobile = useMediaQuery('(max-width: 768px)')

// Custom server fallback
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)', true)

Shared Utilities

Common patterns extracted to packages/react/src/shared/:

Utility Purpose
createDebounceConfig() Module-level debounce configuration
useLatestCallback() Stable callback ref pattern (avoids stale closures)
LazyReturn<T, L> Generic type for lazy mode returns
ObserverHookReturn<T, L> Standard observer hook return tuple

Technical Details

Why useSyncExternalStore?

From This Week in React:

  1. Concurrent rendering safety - Prevents tearing when React renders different parts of the tree with different store values
  2. Selective subscriptions - Each hook's getSnapshot returns only what it needs, preventing over-rendering
  3. Built-in SSR support - getServerSnapshot provides consistent server-side values

Hooks Updated

Hook Changes
useWindowSize useSyncExternalStore, single state object instead of 3 useState
useWindowWidth/Height/Dpr New - selective subscriptions
useMediaQuery useSyncExternalStore, SSR fallback parameter
useResizeObserver SSR safety, shared utilities, removed deps
useIntersectionObserver SSR safety, shared utilities, removed deps
useRect useSyncExternalStore for emitter, try/finally for DOM safety, removed deps
useLazyState Shared utilities, removed deps
useObjectFit Removed useMemo (pure computation)
useDebouncedEffect/State/Callback Shared utilities, better types

Security Improvements

  • Added try/finally to sticky position manipulation in useRect - ensures DOM styles are restored even if measurement throws

CI/CD Updates

  • GitHub Actions updated to v4 (checkout, setup-node)
  • Node.js upgraded from 16 (EOL) to 22 (current LTS)
  • Migrated from pnpm to bun

Playground Overhaul

The playground has been completely rewritten with interactive demos for all hooks:

Demo Description
useWindowSize Live window dimensions + selective hooks comparison
useMediaQuery Device breakpoint indicators + system preferences
useResizeObserver Resizable box with live size display
useRect Resizable box with position tracking + "Add block above" to demo top changes
useIntersectionObserver Scroll-triggered visibility detection
useDebouncedState Side-by-side instant vs debounced counter
useLazyState Render count tracker showing zero re-renders

Updated branding with new hamo mark throughout.

Documentation Updates

  • Removed outdated deps parameter from useRect README (was removed in v2.0)
  • Fixed setDebounce reference in useRect docs
  • Updated playground copy to accurately describe useRect behavior

Test Plan

  • Verify build passes: bun run build
  • Test playground runs correctly
  • Interactive demos for all exported hooks
  • useRect demo shows all values updating (top/left/width/height)
  • Test in satus starter kit
  • Verify SSR behavior in Next.js app
  • Test concurrent mode with <StrictMode>
  • Verify selective hooks only re-render on relevant changes

Files Changed

packages/react/src/shared/                        # NEW - shared utilities
├── debounce-config.ts
├── types.ts
├── use-latest-callback.ts
└── index.ts
packages/react/src/use-window-size/index.ts       # +selective hooks, useSyncExternalStore
packages/react/src/use-media-query/index.ts       # useSyncExternalStore, SSR fallback
packages/react/src/use-resize-observer/index.ts   # SSR safety, shared utilities
packages/react/src/use-rect/index.ts              # useSyncExternalStore, try/finally safety
packages/react/src/use-rect/README.md             # Removed outdated deps param
packages/react/src/use-intersection-observer/     # SSR safety, shared utilities
packages/react/src/use-lazy-state/index.ts        # Shared utilities
packages/react/src/use-debounce/index.ts          # Shared utilities, better types
packages/react/src/use-object-fit/index.ts        # Removed useMemo
packages/react/index.ts                           # New exports
package.json                                      # React 18+, bun workspaces
.github/workflows/publish.yml                     # Node 22, bun
playground/react/app.tsx                          # Interactive hook demos + improved useRect
playground/react/style.css                        # Updated styling + resizable rect-box
playground/www/layouts/Layout.astro               # New branding
playground/www/pages/index.astro                  # New homepage
playground/public/favicon.svg                     # New favicon

References

BREAKING CHANGES:
- Minimum React version bumped from 17 to 18
- Removed `deps` parameter from useResizeObserver, useIntersectionObserver, useLazyState, useRect
- useMediaQuery now returns boolean instead of boolean | undefined

New features:
- useSyncExternalStore for concurrent-safe subscriptions in useWindowSize, useMediaQuery, useRect
- Selective hooks: useWindowWidth, useWindowHeight, useWindowDpr
- SSR safety checks in all hooks
- Improved TypeScript types (removed @ts-ignore, proper generics)

Other changes:
- Migrated from pnpm to bun
- Removed useMemo from useObjectFit (pure computation)
- Cleaned up dead code in use-debounce
- Added MIGRATION.md documentation
@arzafran arzafran requested a review from clementroche January 28, 2026 13:00
Consolidations:
- Extract createDebounceConfig to shared/debounce-config.ts
- Extract useLatestCallback to shared/use-latest-callback.ts
- Extract LazyReturn types to shared/types.ts
- Add try/finally for DOM style restoration in useRect

CI updates:
- Upgrade actions/checkout and actions/setup-node to v4
- Upgrade Node.js from 16 to 22 (current LTS)
- Replace pnpm with bun in publish workflow

Also fixes:
- Update playground to use new useDebouncedCallback API (2 args)
- Replace old logo with new hamo mark across all pages
- Rewrite React playground with interactive demos for all hooks
- Add visual feedback for useWindowSize, useMediaQuery, useRect
- Add resizable box for useResizeObserver demo
- Add scroll-triggered demo for useIntersectionObserver
- Add side-by-side comparison for useDebouncedState
- Add render count tracking for useLazyState demo
- Update homepage with hooks overview and navigation
- Remove broken core.astro page
- Apply consistent dark theme styling
- Remove eslint in favor of biome-only (matching satus)
- Bump biome to 2.3.13 (matching satus)
- Update biome.json with comprehensive lint rules from satus
- Add lint, lint:fix, format, and check scripts
- Update .vscode/settings.json for biome
- Auto-fix import sorting and formatting
- Add noop comments to satisfy noEmptyBlockStatements
- Exclude .astro files from biome (unsupported syntax)
- Cache WindowSize object reference in useWindowSize
- Cache primitive values for selective hooks (width, height, dpr)
- Restore missing imports in Astro pages (removed by biome)
- Only create new snapshot objects when values actually change

Fixes: "The result of getSnapshot should be cached" React warning
- Make rect-box resizable so width/height values update on drag
- Add "Add block above" button to demonstrate top value changes
- Update copy from incorrect "scroll to see values" to accurate description
- Remove outdated deps parameter from useRect README (removed in v2.0)
- Fix setDebounce reference in docs
- Add .tldr to gitignore
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants