@desolint/hooks
v0.0.1
Published
Framework-agnostic React hooks (list filtering — search/sort/pagination, client & server-side — plus value debouncing and a promise runner) for Desolint frontend projects.
Maintainers
Readme
@desolint/hooks
Framework-agnostic React hooks for Desolint frontend projects — list filtering
(search / sort / pagination, client- and server-side), value debouncing, and a
promise runner, with zero hard dependency on nuqs, next, react-i18next,
or any data-fetching library.
Requirements
- React 18 or newer (peer dependency)
- npm 7 or newer — npm 7+ installs peer dependencies automatically
Install
npm install @desolint/hooksreact (>=18) is the only dependency, and it is a peer — npm reuses the
copy your app already has. The package pulls in nothing else: no nuqs, no
next, no state-management library, no data-fetching client.
Neither installs peer dependencies automatically, so name react explicitly:
yarn add @desolint/hooks react
# or
pnpm add @desolint/hooks reactWhy react is a peer, not a regular, dependency: two copies of React break
hooks outright — React keeps hook state in module-level internals, so a
component rendered by one copy calling a hook from another throws
Invalid hook call. Declaring it as a peer means npm reuses the copy your
application already has instead of nesting a second one.
What's in the box
| Hook | Purpose |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| useDebounce | Debounce a changing value. |
| usePromise | Run an async function, track {isLoading, data, error}. |
| useSearchList | Client-side text search over a list (nested keys supported). |
| useSortList | Client-side sort of a list (nested keys, direction). |
| usePagination | Slice a list into pages / drive server-side pagination. Returns props; you render the UI. |
| useClientSideFilteredLists | useSearchList + useSortList + usePagination, composed, over an in-memory list. |
| useServerSideListFilters | The same composition, but the list comes from a server query you supply. |
| useMemoryFilterState | The only shipped useFilterState adapter — useState-backed, no persistence. |
The useFilterState adapter
The list hooks need somewhere to keep filter state (the search term, sort
column, page number). The package does not pick a store for you — it accepts a
useFilterState adapter so you choose: the URL (nuqs), a useState,
Zustand, Redux Toolkit, anything.
type UseFilterState = <T extends Record<string, string | number>>(
defaults: T
) => {
values: T;
setValues: (patch: Partial<T> | ((prev: T) => Partial<T>)) => void;
};The package ships useMemoryFilterState — a useState-backed adapter with no
URL sync. Pass it straight through:
import {
useClientSideFilteredLists,
useMemoryFilterState,
} from '@desolint/hooks';
useClientSideFilteredLists({
list,
listType,
useFilterState: useMemoryFilterState,
});For any other store, write a ~10-line adapter against the contract and
pass it the same way. See
examples/filter-state-adapter.tsx for
the shape.
URL sync with nuqs — install nuqs in your app, mount its <NuqsAdapter>
(nuqs/adapters/next/app, /react, …), then:
import {parseAsInteger, parseAsString, useQueryStates} from 'nuqs';
import type {FilterStateShape, UseFilterState} from '@desolint/hooks';
export const useNuqsFilterState: UseFilterState = <T extends FilterStateShape>(
defaults: T
) => {
const parsers: Record<string, unknown> = {};
for (const [key, value] of Object.entries(defaults)) {
parsers[key] =
typeof value === 'number'
? parseAsInteger.withDefault(value)
: parseAsString.withDefault(String(value));
}
const [values, setValues] = useQueryStates(parsers as never);
return {values: values as never, setValues: setValues as never};
};Zustand — values from a selector, setValues from a patchFilters action
that shallow-merges. Redux Toolkit — useSelector for values, a wrapper
around dispatch(patchFilters(...)) for setValues.
defaults must be a stable reference — pass a module-level constant, not an
object literal created inside render.
Examples
Runnable, self-contained snippets live in examples/:
use-debounce.tsxuse-promise.tsxclient-side-filtered-list.tsxserver-side-list-filters.tsxfilter-state-adapter.tsx
useDebounce
import {useState} from 'react';
import {useDebounce} from '@desolint/hooks';
const [term, setTerm] = useState('');
const debouncedTerm = useDebounce({value: term, delay: 400}); // delay defaults to 500usePromise
import {usePromise} from '@desolint/hooks';
const {isLoading, data, error, executePromise} = usePromise<User>({
fallbackErrorMessage: t('error.message'), // used when the caught error carries no message
});
executePromise({
fn: () => api.getUser(id),
onSuccess: (user) => {},
onError: (err) => {},
});useClientSideFilteredLists
import {
useClientSideFilteredLists,
useMemoryFilterState,
type PaginationProps,
} from '@desolint/hooks';
const LIST_TYPE = {
search: {keys: ['name', 'email']},
page: {number: 1, limit: 10},
} as const;
function UsersTable({users}: {users: User[]}) {
const {filteredData, paginationProps, filters, setFilters, resetFilters} =
useClientSideFilteredLists({
list: users,
listType: LIST_TYPE,
useFilterState: useMemoryFilterState,
});
return (
<>
<input
value={filters.search}
onChange={(e) => setFilters({search: e.target.value})}
/>
{filteredData.map((u) => (
<Row key={u.id} user={u} />
))}
<Pagination {...paginationProps} />
</>
);
}
// You own the component — the package only hands you the props.
function Pagination({
handleNextPage,
handlePreviousPage,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
}: PaginationProps) {
return (
<div>
<button onClick={handlePreviousPage} disabled={!hasPreviousPage}>
Prev
</button>
<span>
{currentPage} / {totalPages || 1}
</span>
<button onClick={handleNextPage} disabled={!hasNextPage}>
Next
</button>
</div>
);
}useServerSideListFilters
import {useServerSideListFilters, useMemoryFilterState} from '@desolint/hooks';
const {filteredData, paginationProps, filters, isLoading, setFilters} =
useServerSideListFilters<User>({
listType: LIST_TYPE,
dataKey: 'docs', // dot paths supported, e.g. "customers.list"
useFilterState: useMemoryFilterState,
// Any hook returning { data?, isLoading, error? }. `data` is expected to
// carry the list under `dataKey` plus `{ pagination: { totalPages } }`.
queryToCall: ({params}) => useFetchUsers(params),
});API
useDebounce({value, delay?})usePromise({fallbackErrorMessage?})→{isLoading, data, error, executePromise}useSearchList({data?, listType, useFilterState, filtersConfig?})useSortList({data?, useFilterState, filtersConfig?})usePagination({data?, listType, useFilterState, filtersConfig?, serverSideComputedTotalPage?})useClientSideFilteredLists({list, listType, useFilterState, filtersConfig?})useServerSideListFilters({listType, queryToCall, dataKey?, useFilterState, filtersConfig?})useMemoryFilterStateDEFAULT_FILTERS_CONFIG,DEFAULT_DEBOUNCE_DELAY,DEFAULT_PROMISE_ERROR_MESSAGE
filtersConfig defaults to DEFAULT_FILTERS_CONFIG
({number: 1, limit: 5, search: '', sortBy: '', sortDir: ''}) — override it to
change the initial page size / defaults.
Full type definitions ship in dist/index.d.ts.
Development
npm install # install dependencies (Husky wires up git hooks)
npm run build # tsc + tsc-alias → dist/ (CJS + .d.ts)
npm run type-check # tsc --noEmit, source + examples/
npm test # vitest
npm run lint # eslintA pre-commit hook runs lint-staged over staged files, and the same
type-check / lint / test / build runs in CI on every push and PR.
Consumed locally via a file:../package-hooks dependency until published —
run npm run build after changing source, and npm install ../package-hooks
in the consuming app to refresh its copy.
eslint-rules/ carries two shared Desolint lint rules that apply here —
enforce-single-object-param and no-spread-operator (the Express-router
rules from the backend packages are not included). .vscode/settings.json
pins the workspace to the project's own TypeScript and enables format-on-save
with Prettier.
License
MIT © Desolint — see LICENSE.
Free to use, modify and redistribute, commercially or otherwise. Provided "as is", without warranty or liability of any kind.
