@ultralab/react-hooks

April 3, 2026 · View on GitHub

4 React hooks that replace 4 npm packages. Zero deps. ~3KB total. Production-tested on ultralab.tw.

npm bundle size license

Why This Exists

You need scroll animations, so you install react-intersection-observer (14KB). You need meta tags, so you install react-helmet (12KB). You need number counters, so you install react-countup (44KB). You need data fetching, so you install swr (41KB).

That's 4 packages, 111KB, 4 node_modules subtrees for basic UI patterns.

Or:

npm install @ultralab/react-hooks   # ~3KB. Zero deps. All four.

What Replaces What

This hookReplacesTheir sizeOur size
useInViewreact-intersection-observer14KB~0.4KB
useMetareact-helmet / react-helmet-async12KB~0.8KB
useCountUpreact-countup44KB~0.3KB
useCachedFetchswr / react-query (basic)41KB~0.5KB
Total4 packages111KB~3KB

The Killer Feature: useMeta Without React Router

Most meta tag libraries assume you use React Router or Next.js. We don't.

useMeta was built for SPAs that route with window.location.pathname + React.lazy() (like ultralab.tw — 10+ pages, zero React Router). It updates <title>, <meta>, <link canonical>, OG tags, and Twitter Cards on mount, and restores them on unmount.

No SSR required. No router dependency. Just works.


Install

npm install @ultralab/react-hooks

Hooks

useInView — Scroll-triggered animations

Tracks whether an element is visible using IntersectionObserver. Disconnects after first trigger by default.

import { useInView } from '@ultralab/react-hooks'

function FadeInSection({ children }) {
  const { ref, isInView } = useInView({ threshold: 0.2 })
  return (
    <div
      ref={ref}
      className={isInView ? 'animate-fade-in-up' : 'opacity-0'}
    >
      {children}
    </div>
  )
}
OptionTypeDefaultDescription
thresholdnumber0.1Visibility ratio to trigger
rootMarginstring'0px'CSS margin around root
triggerOncebooleantrueFire once then disconnect

Stagger pattern:

{items.map((item, i) => (
  <div
    className={isInView ? 'animate-fade-in-up' : 'opacity-0'}
    style={{ animationDelay: `${i * 0.1}s` }}
  >
    {item.title}
  </div>
))}

useCountUp — Animated number counter

Ease-out cubic animation from 0 to target. Respects prefers-reduced-motion.

import { useInView, useCountUp } from '@ultralab/react-hooks'

function Stats() {
  const { ref, isInView } = useInView()
  const users = useCountUp(12500, 2000, isInView)
  const revenue = useCountUp(47800, 1500, isInView)

  return (
    <div ref={ref}>
      <span>{users.toLocaleString()} users</span>
      <span>${revenue.toLocaleString()} MRR</span>
    </div>
  )
}
ParamTypeDefaultDescription
endnumberTarget number
durationnumber2000Animation ms
startbooleantrueWhen to begin

useMeta — SPA meta tag manager (no Router needed)

Updates <title>, description, canonical, OG, Twitter Card. Restores on unmount.

import { useMeta } from '@ultralab/react-hooks'

function ProductPage() {
  useMeta({
    title: 'UltraProbe — AI Security Scanner',
    description: 'Scan your AI prompts for 12 attack vectors in 5 seconds.',
    canonical: 'https://ultralab.tw/probe',
    ogImage: 'https://ultralab.tw/ultraprobe-og.png',
    themeColor: '#0A0A12',
  })
  return <main>...</main>
}
FieldTypeRequiredDescription
titlestringYesPage title
descriptionstringYesMeta description
canonicalstringYesCanonical URL
ogTitlestringNoOG title (defaults to title)
ogDescriptionstringNoOG description
ogImagestringNoOG image URL
ogTypestringNoOG type (website, article)
themeColorstringNoTheme color meta

How it works:

Mount:   saves current meta → applies your config
Unmount: restores saved meta (navigation "back" works)

No context provider. No <Helmet> wrapper. Just call the hook.


useCachedFetch — Deduplicated data fetching

Module-level cache. Multiple components requesting the same URL = single fetch.

import { useCachedFetch } from '@ultralab/react-hooks'

function Dashboard() {
  const { data, loading } = useCachedFetch({
    url: '/api/stats',
    fallback: { users: 0, revenue: 0 },
    transform: (raw: any) => ({
      users: raw.total_users,
      revenue: raw.monthly_revenue,
    }),
  })

  if (loading) return <Skeleton />
  return <StatsGrid data={data} />
}
FieldTypeRequiredDescription
urlstringYesGET endpoint
fallbackTYesValue while loading / on error
transform(data: unknown) => TNoTransform raw JSON

Not a replacement for react-query if you need mutations, pagination, or revalidation. But for simple GET + cache — ~0.5KB vs 41KB.


Production Usage

These hooks power ultralab.tw:

  • 10+ SPA pages routed via window.location.pathname (no React Router)
  • Scroll animations on every section
  • Dynamic meta tags per page (SEO-optimized, crawlers see correct metadata)
  • Real-time stats dashboard
  • 7,500+ monthly active users

Requirements

  • React >= 17
  • TypeScript (optional but recommended)

License

MIT — Ultra Lab