Installation
pnpm add @hookraft/useengagementuseEngagement
import { useEngagement } from "@hookraft/useengagement"const { data, isActive, flush } = useEngagement({
onSync: async (data) => {
await fetch("/api/analytics", {
method: "POST",
body: JSON.stringify(data),
})
},
idleTimeout: 3000,
trackClicks: true,
trackScroll: true,
})What it is
useEngagement silently tracks how a user behaves on a page — clicks, scroll depth, active time, and idle time. When the user leaves, it sends the data to your backend.
No third party analytics. Just your data, your backend, your control.
User lands on page
↓
Hook starts tracking automatically:
- active/idle time ticking every second
- clicks counted + element IDs recorded
- scroll depth updated as user scrolls
↓
User leaves (tab close, navigate away, tab switch)
↓
If online → onSync fires instantly
If offline → data saved to localStorage
syncs automatically when network returns
Full Examples
Basic page analytics
Track every page in your app by adding the hook to a shared layout or page component.
import { useEngagement } from "@hookraft/useengagement"
function BlogPost({ postId }: { postId: string }) {
useEngagement({
onSync: async (data) => {
await fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...data,
postId,
userId: getCurrentUserId(),
device: navigator.userAgent,
}),
})
},
idleTimeout: 5000,
})
return <article>...</article>
}Show live engagement data
Use data to display real-time engagement info — useful for debugging or building an engagement indicator.
import { useEngagement } from "@hookraft/useengagement"
function Page() {
const { data, isActive } = useEngagement({
onSync: async (data) => await sendToBackend(data),
})
return (
<div>
<article>Your content here</article>
<div>
<p>Active: {isActive ? "yes" : "idle"}</p>
<p>Active time: {data.activeTime}s</p>
<p>Idle time: {data.idleTime}s</p>
<p>Scroll depth: {data.scrollDepth}%</p>
<p>Total clicks: {data.totalClicks}</p>
<p>Clicked elements: {data.clickTargets.join(", ") || "none"}</p>
</div>
</div>
)
}Manual flush before navigation
Call flush() before navigating away to make sure data is sent before the page unmounts.
import { useEngagement } from "@hookraft/useengagement"
import { useRouter } from "next/navigation"
function CheckoutPage() {
const router = useRouter()
const { flush } = useEngagement({
onSync: async (data) => await sendAnalytics(data),
})
const handleContinue = async () => {
flush()
router.push("/checkout/payment")
}
return (
<div>
<button onClick={handleContinue}>Continue to payment</button>
</div>
)
}Offline support
Data is automatically saved to localStorage when the user is offline and synced when the network returns. You don't need to do anything extra — it's built in.
useEngagement({
onSync: async (data) => {
await fetch("/api/analytics", {
method: "POST",
body: JSON.stringify(data),
})
},
storageKey: "my_app_engagement",
maxQueue: 30,
})What your backend receives
// POST /api/analytics
{
pageUrl: "https://myapp.com/blog/how-to-use-react",
totalClicks: 7,
clickTargets: ["subscribe-btn", "share-btn", "nav-home"],
activeTime: 142,
idleTime: 38,
scrollDepth: 84,
enteredAt: 1743200000000,
exitAt: 1743200180000
}Add your own metadata (userId, device, region) inside onSync before sending to your backend — useEngagement intentionally leaves those fields to you.
How active time works
The hook runs a ticker every second. Each tick is classified as either active or idle:
active → user moved mouse, typed, scrolled, or clicked in the last idleTimeout ms
idle → no activity for idleTimeout ms
hidden → user switched to another tab
Only active ticks add to activeTime. Idle and hidden ticks add to idleTime. This means activeTime + idleTime = total time on page.
How offline sync works
User goes offline
↓
onSync is skipped
↓
Data saved to localStorage under storageKey
↓
Network returns
↓
Queued entries sent to onSync one by one
↓
localStorage cleared on success
If sending a queued entry fails, the loop stops to avoid partial sends. The remaining entries stay in localStorage for the next opportunity.
Options
| Prop | Type | Default |
|---|---|---|
onSync | (data: useEngagement.EngagementData) => void | Promise<void> | - |
idleTimeout | number | 3000 |
trackClicks | boolean | true |
trackScroll | boolean | true |
storageKey | string | hookraft_engagement_queue |
maxQueue | number | 20 |
Returns
| Prop | Type | Default |
|---|---|---|
data | useEngagement.EngagementData | - |
isActive | boolean | - |
flush() | () => void | - |
EngagementData shape
| Prop | Type | Default |
|---|---|---|
pageUrl | string | - |
totalClicks | number | - |
clickTargets | string[] | - |
activeTime | number | - |
idleTime | number | - |
scrollDepth | number | - |
enteredAt | number | - |
exitAt | number | null | - |