@spa-tools
Interaction Hooks
The @spa-tools/interaction-hooks
package is a small library of specialized hooks aimed at solving interaction scenarios in modern-day React applications.
Feature highlights include:
- Time-saving functionality
- Production-tested
- TypeScript First
- Zero Dependencies
- Tree-shakable
Hooks in this package:
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.
Installation
npm install @spa-tools/interaction-hooks
Hooks / Usage
useCallOnce
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';
function logOnce(message: string) {
console.log('This will only log once:', message);
}
export function UseCallOnceHookExample() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
useCallOnce(logOnce, 'Hello, world!');
return <p>Current time: {time.toLocaleTimeString()}</p>;
}
useDetectKeyDown
Hook that watches for requeste keys/modifiers to be pressed.
import { useEffect, useRef } from 'react';
import { useDetectKeyDown } from '@spa-tools/interaction-hooks';
function UseDetectKeyDownExample() {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const [onKeyDownInput1, pShiftControlKeysDetected] = useDetectKeyDown('P', ['Shift', 'Control']);
const [onKeyDownInput2] = useDetectKeyDown('Enter', submitButtonRef);
useEffect(() => {
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>
);
}
useInfiniteScroll
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';
function useGetRecipes() {
return useCallEndpoint(
'https://dummyjson.com/recipes',
{
requestOptions: { recordLimit: 10 },
serverModelOptions: { jsonDataDotPath: 'recipes' },
},
true
);
}
function UseInfiniteScrollExample() {
const scrollTargetRef = useRef<HTMLDivElement>(null);
const [total, setTotal] = useState(0);
const [count, setCount] = useState(0);
const [getRecipes, recipesResult, isRecipesCallPending] = useGetRecipes();
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) {
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>
);
}
useIsHovered
Hook that watches elements for hover state.
import { useRef } from 'react';
import { useIsHovered } from '@spa-tools/interaction-hooks';
function UseIsHoveredExample() {
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const spanRef = useRef<HTMLSpanElement>(null);
const arrayRef = useRef<HTMLButtonElement[]>([]);
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>
);
}
useIsOverflowed
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>
);
}
useQueryState
Hook that manages interaction with the URLs querystring.
import { useQueryState } from '@spa-tools/interaction-hooks';
interface SortColumnInfo {
sortColumn: string;
sortDirection: 'ASC' | 'DESC';
}
function UseQueryStateExample() {
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>
);
}
Docsite
View the @spa-tools documentation site for complete reference.
Contributing
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.
Unit Test Coverage
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.
License
All packages in @spa-tools are licensed under the MIT license. Copyright © 2024, Ryan Howard (rollercodester). All rights reserved.