StunkStunk
Getting Started

Changelog

A history of Stunk releases, API changes, and what's coming next.

Stunk follows semantic versioning. Minor and patch releases are fully backward-compatible. Breaking changes only happen in major versions.


[3.1.0] - 2026-07-08

Added

  • scoped option on asyncChunk and paginatedAsyncChunk — when true, useAsyncChunk/useInfiniteAsyncChunk transparently give each calling component its own isolated instance instead of sharing the exported chunk's state. The export syntax is unchanged — no factory function or useState wrapper needed at the call site.
export const searchResultsChunk = paginatedAsyncChunk(fetcher, {
  pagination: { pageSize: 20, mode: "replace" },
  scoped: true,
});

// Each component using searchResultsChunk now gets independent
// data/pagination/params state.

Use scoped: true for parameterized or filtered data (search results, tables with independent filters, multiple simultaneous tabs) where sharing one singleton across consumers causes them to fight over setParams/nextPage/cached data. Leave the default (false) for true app-wide singletons (current user, wallet, notifications) where every consumer must observe the same state.

Only useAsyncChunk/useInfiniteAsyncChunk resolve scoped — calling methods directly on the exported chunk outside a component still operates on the one shared singleton instance.

  • reset(refetch?: boolean)reset() now accepts an optional parameter (default true) controlling whether it triggers a refetch after clearing state. Pass reset(false) for flows like logout, where state should clear without firing an unauthenticated request.
notificationsChunk.reset(); // clears and refetches (default)
notificationsChunk.reset(false); // clears only — no refetch

Fixed

  • reload() now resets pagination cursor and page to initial state. Previously, reload() reused whatever cursor/page was left over from the last nextPage() call, so repeated reload() calls (from SSE updates, login, component remounts) silently advanced through pages instead of always returning to page 1 — the most common symptom being a paginated list quietly swapping to unrelated data on refresh.
  • In-flight request deduplication is now keyed by params, not just the chunk key. Two calls to fetchData with different params (e.g. an unfiltered mount-time reload() immediately followed by a filtered setParams()) previously deduped onto the same in-flight promise, since only the chunk's key was used for lookup. The second, correct request silently never reached the network — only the first (wrong) response was ever applied. This is the root cause behind filters not applying on first page load in some apps.
  • cancel() updated to match the new params-aware dedup key — it previously deleted the in-flight entry under the old bare chunk-key, which no longer matched anything after the dedup fix above, leaving stale in-flight promises un-cancellable.
  • Cache eviction (cacheTime) is now gated by active subscriber count. Previously, cacheTime scheduled an unconditional clearCache() regardless of whether the chunk still had active subscribers — so live, in-use data could silently be wiped to null mid-session once the timer elapsed, even while a component was still mounted and displaying it. Eviction now only fires once subscriberCount reaches zero, matching the documented intent of "cache retained after last subscriber leaves."
  • fetchOnMount no longer bypasses enabled in useAsyncChunk. The mount effect's condition previously short-circuited on fetchOnMount alone (fom || (...)), ignoring enabled entirely — chunks configured with both fetchOnMount: true and enabled: someCondition (e.g. isLoggedIn) fetched on mount even when the condition was false, producing unauthenticated requests immediately after logout + refresh. Now correctly requires enabled && (fom || ...).
  • reset() now refetches paginated chunks. Previously reset() only auto-refetched when the fetcher took no params (!expectsParams), which unconditionally skipped every paginated chunk (their fetcher signature always expects page/pageSize/cursor). Paginated chunks manage pagination internally and don't need external params to refetch, so they're now included.
  • useAsyncChunk resolves scoped/passed-in chunk instances via useMemo keyed on the chunk reference, not a one-time useState initializer — restoring correct behavior when a component rerenders with a genuinely different chunk instance (e.g. switching from a paginated to a non-paginated chunk, or vice versa).

Removed

  • initialParams fully removed from UseAsyncChunkOptions and UseInfiniteAsyncChunkOptions. It has been deprecated since 3.0.0-rc.10 in favor of params; both the type and the runtime fallback are now gone. Update any remaining useAsyncChunk(chunk, { initialParams }) call sites to useAsyncChunk(chunk, { params }).

[3.0.6] - 2026-07-01

Fixed

  • 403 responses are now tagged nonRetryable at the HTTP-client layer and fetchData's retry loop respects it — a 403 (permission denied) no longer burns through retryCount attempts that cannot possibly succeed, since retrying with the same token changes nothing. (This pattern is app-level — see your HTTP client's response interceptor — but asyncChunk's retry logic now checks error.nonRetryable before retrying.)

[3.0.3] - 2026-06-27

Fixed

  • reload() did not previously reset a paginated chunk's cursor/page before fetching, causing chained calls to silently advance through pages — see the fuller writeup under 3.1.0 above, first fixed here and hardened further in 3.1.0.

[3.0.2] - 2026-06-25

Added

  • clearOnParamChange option on asyncChunk and paginatedAsyncChunk — when true, setParams() immediately clears data to null before the new fetch resolves. Eliminates stale data flash when navigating between detail views that share a single chunk. Default: false.
export const jobDetailChunk = asyncChunk(
  (params: { ref: string }) => api.getJobDetail(params.ref),
  { staleTime: 0, clearOnParamChange: true },
);

Fixed

  • useAsyncChunk — deduplicated double-fetch when enabled flips false → true and params change in the same render. Previously both the enabled effect and the paramsKey effect fired setParams simultaneously, causing two network requests and a potential race condition. The enabled effect now signals the paramsKey effect to skip when it has already dispatched.
  • mutate() return type corrected from (mutator: (currentData: T | null) => T) to (mutator: (currentData: T | null) => T | null) — allows returning null from a mutator, which is consistent with data being typed T | null.

[3.0.1] - 2026-06-24

Fixed

  • Added cancel() method to AsyncChunk — cancels any in-flight request and sets loading to false immediately
  • Added isCancelled flag in fetchData — prevents cancelled async closures from setting data after cancellation
  • useAsyncChunk hook now calls cancel() when enabled flips from true to false
  • useAsyncChunk hook now uses setParams() instead of reload() when enabled flips from false to true and params are present — fixes fetch not triggering when currentParams was empty
  • setParams() and reload() now reset isCancelled flag before starting a new fetch

Added

  • cancel: () => void to AsyncChunk interface

Use case unlocked

  • Factory pattern (createXChunk) now works correctly with enabled: isOpen for modal data fetching — no background fetching after modal close, no stale data on reopen

v3.0.0-rc.13-14

Patch release. Fixes a stale-response race condition when setParams is called while a fetch is in flight.

Fix

When setParams was called while a previous fetchData was still awaiting a response, the old request could resolve after the new one — overwriting the correct data with stale results. This caused "one step behind" behavior in paginated lists with status/filter dropdowns.

Fixed by calling inFlightRequests.delete(chunkKey) inside setParams before starting the new fetch, so the old promise is dropped and a fresh request always wins.


v3.0.0-rc.11

Release candidate. Adds cursor-based pagination support.

New — cursor-based pagination

paginatedAsyncChunk and infiniteAsyncChunk now support cursor-based pagination alongside the existing page-number pagination, for APIs that return an opaque nextCursor instead of working with page numbers (common with cursor-paginated REST APIs and GraphQL-style connections).

Enable it via pagination.cursorMode:

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: (response) => response.cursor },
  },
);

conversations.nextPage(); // fetches using the cursor from the last response

This mirrors TanStack Query's getNextPageParam pattern — your fetcher receives whatever cursor the previous response returned, rather than Stunk assuming pages are numbered.

PaginationState gains an optional cursor field. The chunk's fetchData passes cursor (not page) to your fetcher when cursor mode is active, and hasMore is derived automatically from whether getNextCursor returns a value.

goToPage and prevPage are no-ops in cursor mode — cursors are forward-only by nature; there is no general way to "go back" without the backend supporting reverse cursors or the frontend tracking a cursor stack itself. nextPage(), resetPagination(), and setParams() all work as expected and correctly reset/advance the cursor.

Fixes

  • None this release — purely additive.

Tests

  • 76 tests passing across asyncChunk (55) and infiniteAsyncChunk (21), including a dedicated cursor-pagination suite covering first-page fetch, nextPage cursor chaining, hasMore derivation, prevPage/goToPage no-op behavior, resetPagination cursor clearing, setParams cursor reset, and a structural guard ensuring page never leaks into the fetcher params when cursor mode is active.

v3.0.0-rc.10

Release candidate. Soak-tested against a production v3 migration.

This release contains a breaking change to asyncChunk's pagination API. See below before upgrading from rc.9 or earlier.

Breaking change — asyncChunk pagination split into paginatedAsyncChunk

asyncChunk's 4-way overload (no-param/param × no-pagination/pagination) frequently resolved to the wrong overload when a chunk was assigned to an explicitly-typed variable, forcing a manual cast at every call site:

// rc.9 and earlier — required a cast
const usersChunk = asyncChunk(fetchUsers, {
  pagination: { pageSize: 10 },
}) as PaginatedParamAsyncChunk
  User[],
  Error,
  { page: number; pageSize: number }
>;

asyncChunk no longer accepts a pagination option. Pagination now lives in a dedicated function with a single, unambiguous signature — no cast required:

// rc.10 — no cast needed
const usersChunk = paginatedAsyncChunk(fetchUsers, {
  pagination: { pageSize: 10, mode: "replace" },
});

This mirrors TanStack Query's useQuery / useInfiniteQuery split — one function, one return shape, no overload resolution.

Migration: find every asyncChunk(fetcher, { pagination: {...} }) call and rename it to paginatedAsyncChunk(fetcher, { pagination: {...} }). Drop any as PaginatedParamAsyncChunk<...> cast — it's no longer needed.

infiniteAsyncChunk is unaffected at the call site — it now calls paginatedAsyncChunk internally instead of the ambiguous asyncChunk, and no longer needs its own internal type cast either. Its public API is unchanged.

Fixes

  • useAsyncChunk now fetches on mount by default when enabled — previously, a chunk created with enabled: false that later became enabled required an explicit fetchOnMount: true to actually fetch once mounted, which didn't match the expected "fetch automatically unless enabled: false" behavior. fetchOnMount is now only needed to force a refetch when data already exists.
  • useInfiniteAsyncChunk now forwards params — previously only the deprecated initialParams reached the underlying fetcher; reactive params passed by the caller were silently dropped, so search/filter inputs never reached the server on subsequent renders.
  • setParams resets paginated chunks to page 1 — previously, changing params while on page 3 would fetch "page 3 with new params" instead of starting over. Affects both paginatedAsyncChunk and infiniteAsyncChunk; accumulated data is also cleared on reset when mode: 'accumulate'.
  • pageSize no longer leaks into fetch params under that name — Stunk's internal pagination state injects page/pageSize automatically, but backends that expect a different name (e.g. limit) would reject the request. Map the field name inside your own fetcher wrapper.

v3.0.0-rc.4 – v3.0.0-rc.9

Release candidates. Incremental fixes shipped during the Nester Verify v3 migration soak test.

Bug Fixes

  • asyncChunk: reactive enabled — when passed as a function, dependencies are now tracked via trackDependencies and the chunk auto-fetches when they change
  • asyncChunk: setupSideEffects() now called on false → true enabled transition so refetchInterval starts correctly after login
  • asyncChunk: guard against duplicate intervals and focus listeners on re-initialization
  • asyncChunk: isNextPageFetch flag — accumulate mode now only accumulates on nextPage(); all other fetches (reload, reset, enabled transition) replace data instead of appending
  • useAsyncChunk: new enabled option for React-level reactive fetch control
  • mutation: invalidates now calls resetPagination() on paginated chunks instead of reload() to prevent stale page state

Exports

  • Added PaginatedParamAsyncChunk to public exports from stunk/query

v3.0.0 — alpha.2

Alpha release. Bundle size: 3.32kB gzipped (core + React + Query).

v3 is in active development. APIs are stable but may change before the final release. For production use, see v2.8.1.

What's new in v3

Core (stunk)

  • peek() — read a chunk value without registering it as a tracked dependency
  • strict mode on ChunkConfig — throws or warns on unknown keys in set() in development
  • null is now a valid chunk value (undefined still forbidden)
  • ReadOnlyChunk<T>derive() now returns a proper read-only type at the TypeScript level
  • trackDependencies exported — for building custom reactive primitives
  • validateObjectShape improved — no longer warns on undefined → T or T → null transitions

Computed (redesigned)

  • Auto-tracks dependencies via .get() calls — no dependency arrays needed
  • Lazy evaluation with eager recompute when subscribers are active
  • isDirty() and recompute() for manual control
  • peek() inside computed() reads without tracking
  • Diamond dependency pattern handled correctly — subscribers notified once
// v2
const total = computed([price, quantity], (p, q) => p * q);

// v3
const total = computed(() => price.get() * quantity.get());

Query — new subpath stunk/query

  • asyncChunk — reactive async state with loading, error, data, lastFetched
  • paginatedAsyncChunk — dedicated page-based pagination, no overload ambiguity (added rc.10); supports cursor-based pagination via cursorMode (added rc.11)
  • infiniteAsyncChunk — accumulate-mode pagination for infinite scroll, page-based or cursor-based
  • combineAsyncChunks — unified loading/error/data across multiple async chunks
  • key option — request deduplication, concurrent calls share one in-flight request
  • keepPreviousData + isPlaceholderData — no UI flicker on param changes
  • onSuccess / onError callbacks
  • enabled as boolean or () => boolean — dynamic disabling
  • setParams with null clearing individual keys, clearParams() to wipe all
  • refetchOnWindowFocus, refetchInterval, staleTime, cacheTime
  • forceCleanup() + ref-counted cleanup()
  • Full pagination — nextPage, prevPage, goToPage, resetPagination
  • SSR-safe — all window access guarded

Middleware (stunk/middleware)

  • history (renamed from withHistory) — reset() now clears the history stack
  • skipDuplicates: true — strict equality only; 'shallow' — shallow equality for objects
  • persist (renamed from withPersistence) — clearStorage() added, onError called on type mismatches, array vs object type mismatch detection

React (stunk/react)

  • useAsyncChunk — rewritten: single effect, Rules of Hooks compliant, exposes isPlaceholderData and clearParams
  • useInfiniteAsyncChunk — stable IntersectionObserver, SSR-safe, correct isFetchingMore
  • useDerive, useComputed, useChunkProperty, useChunkValues — removed (use useChunkValue(computed(...)) instead)

Breaking changes from v2

Changev2v3
computed() APIcomputed([deps], fn)computed(() => fn)
withHistoryimport { withHistory }import { history }
withPersistenceimport { withPersistence }import { persist }
asyncChunk importstunkstunk/query
useDerive / useComputedavailableremoved
subscribe fires on subscribeyesno — only on change
null as chunk valueforbiddenallowed

v2.8.1 — Stable

Latest stable release. Bundle size: 2.95kB gzipped (core + React).

What's in v2

Core (stunk)

  • chunk() — atomic state primitive with get(), set(), reset(), destroy(), subscribe(), derive()
  • computed() — derive state from multiple chunks with dependency arrays and isDirty() tracking
  • select() — read-only derived chunk with optional shallow equality (useShallowEqual)
  • asyncChunk() — async state with built-in loading, error, data, and reload
  • infiniteAsyncChunk() — paginated / infinite-scroll async state
  • batch() — group multiple state updates into a single render cycle

Middleware (stunk/middleware)

  • logger — logs every set() call
  • withHistory() — undo/redo history (undo, redo, canUndo, canRedo, getHistory, clearHistory, maxHistory)
  • withPersistence() — localStorage persistence with custom serialize/deserialize
  • nonNegativeValidator — throws if a numeric value goes below zero

React (stunk/react)

  • useChunk — read and write a chunk reactively
  • useChunkValue — read-only subscription
  • useDerive — derive a value from a single chunk
  • useComputed — compute a value from multiple chunks
  • useAsyncChunk — async state hook
  • useInfiniteAsyncChunk — infinite scroll hook

v1 → v2 Migration

set() and update() merged

// v1
count.set(10);
count.update((n) => n + 1);

// v2+
count.set(10);
count.set((n) => n + 1); // update() removed

Release History

VersionStatusHighlights
3.1.0✅ Latestscoped option, reset(refetch), dedup-by-params fix, subscriber-gated cache eviction, initialParams removed
3.0.6✅ Stable403 tagged nonRetryable, skipped in retry loop
3.0.3✅ Stablereload() cursor/page reset fix
3.0.2✅ StableclearOnParamChange, double-fetch fix, mutate type fix
3.0.1✅ Stablecancel(), enabled flip fixes
3.0.0-rc.11🚧 RCCursor-based pagination support
3.0.0-rc.10🚧 RCpaginatedAsyncChunk split, fetchOnMount default, params forwarding fix
3.0.0-rc.4 – rc.9🚧 RCReactive enabled, accumulate-mode fix, invalidates pagination fix
3.0.0-alpha🚧 AlphaComputed redesign, stunk/query, strict mode, null values
2.8.1✅ StableLatest stable, 2.95kB gzipped
2.x✅ StableFull React integration, async, middleware, time travel
1.x⚠️ DeprecatedEarly API, no longer supported

On this page