stunk
Version:
Stunk is a lightweight, framework-agnostic state management library for JavaScript and TypeScript. It uses chunk-based state units for efficient updates, reactivity, and performance optimization in React, Vue(WIP), Svelte(Coming soon), and Vanilla JS/TS a
284 lines (279 loc) • 13 kB
TypeScript
import { C as Chunk } from './core-DAc7hZla.js';
interface AsyncState<T, E extends Error> {
loading: boolean;
error: E | null;
data: T | null;
lastFetched?: number;
/** True when showing previous data while new data is loading (keepPreviousData: true) */
isPlaceholderData?: boolean;
}
interface PaginationState {
page: number;
pageSize: number;
total?: number;
hasMore?: boolean;
/** Current cursor — only populated when using cursor-based pagination */
cursor?: string;
}
interface AsyncStateWithPagination<T, E extends Error> extends AsyncState<T, E> {
pagination?: PaginationState;
}
interface ParamAsyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}> extends AsyncChunk<T, E> {
setParams: (params: Partial<Record<keyof P, P[keyof P] | null>>) => void;
clearParams: () => void;
reload: (params?: Partial<P>) => Promise<void>;
refresh: (params?: Partial<P>) => Promise<void>;
}
interface PaginatedParamAsyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}> extends PaginatedAsyncChunk<T, E> {
setParams: (params: Partial<Record<keyof P, P[keyof P] | null>>) => void;
clearParams: () => void;
reload: (params?: Partial<P>) => Promise<void>;
refresh: (params?: Partial<P>) => Promise<void>;
}
interface FetcherResponse<T> {
data: T;
total?: number;
hasMore?: boolean;
/** Next cursor — only used when `pagination.cursorMode` is configured */
cursor?: string;
}
interface CursorModeConfig<T> {
/**
* Extracts the next cursor from a fetch response. Return `undefined`
* when there are no more pages.
*/
getNextCursor: (response: FetcherResponse<T>) => string | undefined;
}
interface AsyncChunkOptions<T, E extends Error = Error, P extends Record<string, any> = {}> {
/** Deduplication key — concurrent calls with the same key share one in-flight request */
key?: string;
/** Seed data shown before the first fetch completes */
initialData?: T | null;
/** Disable fetching until ready — pass a function for dynamic evaluation */
enabled?: boolean | ((params: Partial<P>) => boolean);
/** Called after every successful fetch */
onSuccess?: (data: T) => void;
/** Called when all retries are exhausted */
onError?: (error: E) => void;
/** Number of retries on failure (default: 0) */
retryCount?: number;
/** Delay in ms between retries (default: 1000) */
retryDelay?: number;
/** Show previous data while refetching — prevents UI flicker on param changes (default: false) */
keepPreviousData?: boolean;
/**
* Clear data to null immediately when params change via setParams(), before
* the new fetch resolves. Eliminates stale data flash when navigating between
* detail views that share a single chunk. (default: false)
*
* @example
* // jobDetailChunk will show null (loading state) immediately when ref changes,
* // instead of showing the previous job's data while the new one loads.
* export const jobDetailChunk = asyncChunk(
* (params: { ref: string }) => jobsApi.getJobDetail(params.ref),
* { staleTime: 0, clearOnParamChange: true }
* );
*/
clearOnParamChange?: boolean;
/** Time in ms before data is considered stale (default: 0) */
staleTime?: number;
/** Time in ms to cache data after last subscriber leaves (default: 300_000) */
cacheTime?: number;
/** Auto-refetch interval in ms */
refetchInterval?: number;
/** Refetch when window regains focus (default: false) */
refetchOnWindowFocus?: boolean;
/**
* When true, useAsyncChunk gives each calling component its own
* independent instance of this chunk instead of sharing the module-level
* singleton. Use for parameterized/filtered data (paginated lists, search
* results) where multiple simultaneous consumers must not share state.
* Leave false (default) for true app-wide singletons (current user,
* wallet balance, notification list) that must stay in sync everywhere.
* (default: false)
*/
scoped?: boolean;
pagination?: {
/** Initial page number (default: 1) */
initialPage?: number;
/** Items per page (default: 10) */
pageSize?: number;
/** Replace data on each page load, or accumulate for infinite scroll (default: 'replace') */
mode?: 'replace' | 'accumulate';
/**
* Switches the chunk to cursor-based pagination. When set, `page` is
* ignored — the chunk tracks an opaque `cursor` string supplied by
* your fetcher's response instead of an incrementing page number.
*/
cursorMode?: CursorModeConfig<T>;
};
}
interface AsyncChunk<T, E extends Error = Error> extends Chunk<AsyncStateWithPagination<T, E>> {
/** Force a fresh fetch, ignoring stale time */
reload: (params?: any) => Promise<void>;
/** Fetch only if data is stale — respects staleTime */
refresh: (params?: any) => Promise<void>;
/** Update data directly without a network request */
mutate: (mutator: (currentData: T | null) => T | null) => void;
/** Reset to initial state and re-fetch */
reset: (refetch?: boolean) => void;
/** Safe cleanup — only tears down if no active subscribers remain */
cleanup: () => void;
/** Force cleanup regardless of subscriber count */
forceCleanup: () => void;
/** Clear all current params and refetch */
clearParams: () => void;
/** Cancel any in-flight request and set loading to false */
cancel: () => void;
}
interface PaginatedAsyncChunk<T, E extends Error = Error> extends AsyncChunk<T, E> {
/** Load the next page */
nextPage: () => Promise<void>;
/** Load the previous page */
prevPage: () => Promise<void>;
/** Jump to a specific page */
goToPage: (page: number) => Promise<void>;
/** Reset pagination to page 1 and re-fetch */
resetPagination: () => Promise<void>;
}
declare function asyncChunk<T, E extends Error = Error>(fetcher: () => Promise<T | FetcherResponse<T>>, options?: Omit<AsyncChunkOptions<T, E>, 'pagination'>): AsyncChunk<T, E>;
declare function asyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}>(fetcher: (params: P) => Promise<T | FetcherResponse<T>>, options?: Omit<AsyncChunkOptions<T, E, P>, 'pagination'>): ParamAsyncChunk<T, E, P>;
/**
* Creates a paginated async state unit — always returns `PaginatedParamAsyncChunk`
* with `nextPage`/`prevPage`/`goToPage`/`resetPagination`.
*/
declare function paginatedAsyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}>(fetcher: (params: P & {
page?: number;
pageSize: number;
cursor?: string;
}) => Promise<FetcherResponse<T>>, options: AsyncChunkOptions<T, E, P> & {
pagination: NonNullable<AsyncChunkOptions<T, E, P>['pagination']>;
}): PaginatedParamAsyncChunk<T, E, P>;
type InfiniteAsyncChunkOptions<T, E extends Error = Error> = Omit<AsyncChunkOptions<T[], E>, 'pagination'> & {
/** Items per page (default: 10) */
pageSize?: number;
/**
* Use cursor-based pagination instead of page-number based.
* Required when the backend returns an opaque cursor (e.g. base64-encoded)
* rather than supporting page numbers directly.
*/
cursorMode?: CursorModeConfig<T[]>;
};
type InfiniteAsyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}> = PaginatedParamAsyncChunk<T[], E, P & {
page?: number;
pageSize: number;
cursor?: string;
}>;
/**
* Creates an infinite scroll async chunk that accumulates pages.
*
* Supports both page-number pagination (default) and cursor-based pagination
* via `options.cursorMode` — use cursor mode when your backend returns an
* opaque `nextCursor` rather than working with page numbers.
*
* @example
* // Page-based (default)
* const posts = infiniteAsyncChunk(
* async ({ page, pageSize }) => fetchPosts({ page, pageSize }),
* { pageSize: 20 }
* );
*
* @example
* // Cursor-based
* const conversations = infiniteAsyncChunk(
* async ({ cursor, pageSize }) => {
* const res = await listConversations({ cursor, limit: pageSize });
* return { data: res.data, hasMore: res.hasMore, cursor: res.nextCursor };
* },
* { pageSize: 20, cursorMode: { getNextCursor: (res) => res.cursor } }
* );
*/
declare function infiniteAsyncChunk<T, E extends Error = Error, P extends Record<string, any> = {}>(fetcher: (params: P & {
page?: number;
pageSize: number;
cursor?: string;
}) => Promise<{
data: T[];
hasMore?: boolean;
total?: number;
cursor?: string;
}>, options?: InfiniteAsyncChunkOptions<T, E>): InfiniteAsyncChunk<T, E, P>;
type MutationFn<TData, TVariables> = (variables: TVariables) => Promise<TData>;
interface MutationOptions<TData, TError extends Error = Error, TVariables = void> {
/** Chunks to automatically reload after a successful mutation */
invalidates?: AsyncChunk<any, any>[];
/** Called after a successful mutation with the returned data and original variables */
onSuccess?: (data: TData, variables: TVariables) => void;
/** Called when the mutation fails with the error and original variables */
onError?: (error: TError, variables: TVariables) => void;
/** Called after every attempt — success or failure — useful for unconditional cleanup */
onSettled?: (data: TData | null, error: TError | null, variables: TVariables) => void;
}
interface MutationState<TData, TError extends Error = Error> {
/** True while the mutation is in progress */
loading: boolean;
/** The data returned from the last successful mutation, or null */
data: TData | null;
/** The error from the last failed mutation, or null */
error: TError | null;
/** True after a successful mutation — distinct from data since data can be null on success */
isSuccess: boolean;
}
interface MutationResult<TData, TError extends Error = Error> {
/** The returned data on success, or null on failure */
data: TData | null;
/** The error on failure, or null on success */
error: TError | null;
}
interface Mutation<TData, TError extends Error = Error, TVariables = void> {
/**
* Execute the mutation. Always resolves — never throws.
* Returns `{ data, error }` so you can await it or fire and forget safely.
*
* @example
* // Fire and forget — safe
* createPost.mutate({ title: 'Hello' });
*
* // Await for local UI control — no try/catch needed
* const { data, error } = await createPost.mutate({ title: 'Hello' });
* if (!error) router.push('/posts');
*/
mutate: (...args: TVariables extends void ? [] : [variables: TVariables]) => Promise<MutationResult<TData, TError>>;
/** Returns the current mutation state */
get: () => MutationState<TData, TError>;
/** Subscribe to state changes. Returns an unsubscribe function. */
subscribe: (callback: (state: MutationState<TData, TError>) => void) => () => void;
/** Reset state back to initial — clears data, error, isSuccess */
reset: () => void;
}
/**
* Creates a reactive mutation for POST, PUT, DELETE, or any async side effect.
*
* Always returns a promise that resolves — never throws.
* On success, automatically reloads any chunks listed in `invalidates`.
*
* @param mutationFn - Async function that performs the side effect.
* @param options.invalidates - Chunks to reload after a successful mutation.
* @param options.onSuccess - Called with data and variables on success.
* @param options.onError - Called with error and variables on failure.
* @param options.onSettled - Called after every attempt regardless of outcome.
*
* @example
* const createPost = mutation(
* async (data: NewPost) => fetchAPI('/posts', { method: 'POST', body: data }),
* {
* invalidates: [postsChunk],
* onSuccess: (data) => toast.success('Post created!'),
* onError: (err) => toast.error(err.message),
* }
* );
*
* // Fire and forget
* createPost.mutate({ title: 'Hello' });
*
* // Await for local control
* const { data, error } = await createPost.mutate({ title: 'Hello' });
* if (!error) router.push('/posts');
*/
declare function mutation<TData, TError extends Error = Error, TVariables = void>(mutationFn: MutationFn<TData, TVariables>, options?: MutationOptions<TData, TError, TVariables>): Mutation<TData, TError, TVariables>;
export { type AsyncChunk as A, type InfiniteAsyncChunk as I, type Mutation as M, type PaginatedAsyncChunk as P, type PaginationState as a, type MutationResult as b, type AsyncState as c, type AsyncStateWithPagination as d, type PaginatedParamAsyncChunk as e, asyncChunk as f, type MutationOptions as g, type MutationState as h, infiniteAsyncChunk as i, type MutationFn as j, mutation as m, paginatedAsyncChunk as p };