The @spa-tools/interaction-hooks
package is a small library of specialized hooks aimed at solving interaction scenarios in modern-day React applications.
- Time-saving functionality
- Production-tested
- TypeScript First
- Zero Dependencies
- Tree-shakable
useCallOnce
useDetectKeyDown
useInfiniteScroll
useIsHovered
useIsOverflowed
useQueryState
It's highly advised to first checkout the @spa-tools documentation site for a complete list of features, usage scenarios, guidance, and reference.
npm install @spa-tools/interaction-hooks
Hook that ensures code runs once and only once no matter how many times your component re-renders.
import { useEffect, useState } from 'react';
import { useCallOnce } from '@spa-tools/interaction-hooks';
// this is the function we want to call only once
function logOnce(message: string) {
console.log('This will only log once:', message);
}
export function UseCallOnceHookExample() {
const [time, setTime] = useState(new Date());
// here we setup some code to force a re-render every second
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
// here we use the hook to call the function
// so that it's guaranteed to only execute once
useCallOnce(logOnce, 'Hello, world!');
return <p>Current time: {time.toLocaleTimeString()}</p>;
}
Hook that watches for requeste keys/modifiers to be pressed.
import { useEffect, useRef } from 'react';
import { useDetectKeyDown } from '@spa-tools/interaction-hooks';
function UseDetectKeyDownExample() {
// here we wire up a ref for the submit button that we will auto-click
const submitButtonRef = useRef<HTMLButtonElement>(null);
// here we ask the hook to set onKeyDownInput1KeyDetected to true
// when the Shift-Ctrl-P keys are pressed
const [onKeyDownInput1, pShiftControlKeysDetected] = useDetectKeyDown('P', ['Shift', 'Control']);
// here we ask the hook to auto-click the submit button when
// the Enter key is pressed
const [onKeyDownInput2] = useDetectKeyDown('Enter', submitButtonRef);
useEffect(() => {
// we simply alert when the Shift-Ctrl-P keys are detected
if (pShiftControlKeysDetected) {
alert('Shift-Ctrl-P detected!');
}
}, [pShiftControlKeysDetected]);
return (
<div>
<div>
<input onKeyDown={onKeyDownInput1} placeholder='Focus here and press Shift-Ctrl-P' />
</div>
<div>
<input onKeyDown={onKeyDownInput2} placeholder='Type something here and press Enter' />
<button
onClick={() => {
alert('Submit button clicked!');
}}
ref={submitButtonRef}
>
Submit
</button>
</div>
</div>
);
}
Hook that enables infinite scroll behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallEndpoint } from '@spa-tools/api-client';
import { useInfiniteScroll } from '@spa-tools/interaction-hooks';
// here we create a custom hook to fetch recipes from a server
// using the useCallEndpoint hook from the @spa-tools/api-client package
function useGetRecipes() {
return useCallEndpoint(
'https://dummyjson.com/recipes',
{
requestOptions: { recordLimit: 10 },
serverModelOptions: { jsonDataDotPath: 'recipes' },
},
// we pass true to enable appending of new records
true
);
}
function UseInfiniteScrollExample() {
// this will hold the ref to our scroll target, which is just a div that we
// place below our list of recipes to act as a sentinel for scroll intersection
const scrollTargetRef = useRef<HTMLDivElement>(null);
const [total, setTotal] = useState(0);
const [count, setCount] = useState(0);
const [getRecipes, recipesResult, isRecipesCallPending] = useGetRecipes();
// anytime our scroll target is intersected for vertical scroll, the hook
// will return true, which is how we know to fetch the next page of recipes
const isScrolling = useInfiniteScroll(scrollTargetRef);
const handleGetRecipes = useCallback(() => {
const recordCount = recipesResult?.data?.length ?? -1;
const totalCount = recipesResult?.total ?? 0;
setCount(recordCount);
setTotal(totalCount);
if (!isRecipesCallPending && recordCount < totalCount) {
getRecipes();
}
}, [getRecipes, isRecipesCallPending, recipesResult?.data?.length, recipesResult?.total]);
useEffect(() => {
if (isScrolling) {
// if the infinite scroll says we're scrolling,
// then we retrieve the next page of recipes
handleGetRecipes();
}
}, [handleGetRecipes, isScrolling]);
return (
<div>
<h4>
{count && total ? `${count === total ? `All ${count}` : `${count} of ${total}`} recipes retrieved!` : ''}
{count && total && count < total ? ' (scroll recipe list to load more)' : ''}
</h4>
<div style={{ height: '300px', overflowY: 'auto', padding: '1rem', width: '100%' }}>
<ul>{recipesResult?.data?.map((recipe) => <li key={recipe.id}>{recipe.name}</li>)}</ul>
{isRecipesCallPending && <div>Loading recipes...</div>}
<div ref={scrollTargetRef} />
</div>
</div>
);
}
Hook that watches elements for hover state.
import { useRef } from 'react';
import { useIsHovered } from '@spa-tools/interaction-hooks';
function UseIsHoveredExample() {
// here we simply setup refs to all elements we want
// to track hover state for
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const spanRef = useRef<HTMLSpanElement>(null);
// yes! you can also track hover state for an array of elements
const arrayRef = useRef<HTMLButtonElement[]>([]);
// then we use different hook instances to track the
// hover state for the above element refs
const isButtonHovered = useIsHovered(buttonRef);
const isInputHovered = useIsHovered(inputRef);
const isSpanHovered = useIsHovered(spanRef);
const isButtonArrayHovered = useIsHovered(arrayRef);
const getHoverStateText = () => {
if (isButtonHovered) {
return 'Very first button is hovered!';
}
if (isInputHovered) {
return 'Input is hovered!';
}
if (isSpanHovered) {
return 'Text is hovered!';
}
if (isButtonArrayHovered) {
return 'One of the six buttons from cluster is hovered!';
}
return 'Nothing is hovered!';
};
return (
<div>
<div>
<button ref={buttonRef}>Hover me!</button>
<input ref={inputRef} value='No, hover over me!' />
<div>
Don't listen to them, hover over{' '}
<span ref={spanRef} style={{ color: 'purple', cursor: 'pointer', fontWeight: 800 }}>
this text
</span>{' '}
instead!
</div>
</div>
<div>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[0] = el)}/>Hover</button>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[1] = el)}/>Over</button>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[2] = el)}/>Any</button>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[3] = el)}/>One</button>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[4] = el)}/>Of</button>
<button ref={(el: HTMLButtonElement) => (arrayRef.current[5] = el)}/>Us</button>
</div>
<h2>{getHoverStateText()}</h2>
</div>
);
}
Hook that watches for when an element's content is overflowed.
import { useRef } from 'react';
import { useIsOverflowed } from '@spa-tools/interaction-hooks';
function UseIsOverflowedExample() {
const sectionRef = useRef<HTMLDivElement>(null);
const isVerticallyOverflowed = useIsOverflowed(sectionRef);
return (
<div ref={sectionRef} style={{ height: '100vh', overflowY: 'auto' }}>
<div style={{ height: '200vh' }}>
<h1>Scroll me</h1>
<h2>{isVerticallyOverflowed ? 'Overflowed vertically' : 'Not overflowed vertically'}</h2>
</div>
</div>
);
}
Hook that manages interaction with the URLs querystring.
import { useQueryState } from '@spa-tools/interaction-hooks';
// here we edefine the shape for our query state
interface SortColumnInfo {
sortColumn: string;
sortDirection: 'ASC' | 'DESC';
}
function UseQueryStateExample() {
// we pass true to the useQueryState hook to enable the cache (i.e. localStorage)
// feature so that the query state for this view is remembered across page reloads
const { queryState, setQueryState } = useQueryState<SortColumnInfo>(true);
return (
<div>
<div>
<button
onClick={() => {
// here we set the query state to sort by age in descending order
setQueryState({ sortColumn: 'age', sortDirection: 'DESC' });
}}
>
Sort by Age (DESC)
</button>
<button
onClick={() => {
// here we set the query state to sort by name in ascending order
setQueryState({ sortColumn: 'name', sortDirection: 'ASC' });
}}
>
Sort by Name (ASC)
</button>
<button
onClick={() => {
// here we hard reload the page with querystring removed
// to test the cache feature
window.location.href = window.location.href.split('?')[0];
}}
>
Hard reload to test cache
</button>
<button
onClick={() => {
// here we clear the query state
setQueryState(null);
}}
>
Clear sort settings
</button>
</div>
<div>
{queryState === null ? (
`Click one of the "Sort by" buttons and watch the browser's URL and also how this text changes!`
) : (
<span>
Sort column <strong>{queryState.sortColumn}</strong> in <em>{queryState.sortDirection}</em> direction!
</span>
)}
</div>
</div>
);
}
View the @spa-tools documentation site for complete reference.
If you're interested in contributing to @spa-tools, please first create an issue on the @spa-tools monorepo in GitHub or comment on an already open issue. From there we can discuss the feature or bugfix you're interested in and how best to approach it.
All packages in @spa-tools require 100% unit test coverage. This is a condition for all PRs to be merged whether you're adding a new feature or fixing a bug.
All packages in @spa-tools are licensed under the MIT license. Copyright © 2024, Ryan Howard (rollercodester). All rights reserved.