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
scopedoption onasyncChunkandpaginatedAsyncChunk— whentrue,useAsyncChunk/useInfiniteAsyncChunktransparently give each calling component its own isolated instance instead of sharing the exported chunk's state. The export syntax is unchanged — no factory function oruseStatewrapper 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 (defaulttrue) controlling whether it triggers a refetch after clearing state. Passreset(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 refetchFixed
reload()now resets pagination cursor and page to initial state. Previously,reload()reused whatever cursor/page was left over from the lastnextPage()call, so repeatedreload()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
fetchDatawith different params (e.g. an unfiltered mount-timereload()immediately followed by a filteredsetParams()) 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,cacheTimescheduled an unconditionalclearCache()regardless of whether the chunk still had active subscribers — so live, in-use data could silently be wiped tonullmid-session once the timer elapsed, even while a component was still mounted and displaying it. Eviction now only fires oncesubscriberCountreaches zero, matching the documented intent of "cache retained after last subscriber leaves." fetchOnMountno longer bypassesenabledinuseAsyncChunk. The mount effect's condition previously short-circuited onfetchOnMountalone (fom || (...)), ignoringenabledentirely — chunks configured with bothfetchOnMount: trueandenabled: someCondition(e.g.isLoggedIn) fetched on mount even when the condition was false, producing unauthenticated requests immediately after logout + refresh. Now correctly requiresenabled && (fom || ...).reset()now refetches paginated chunks. Previouslyreset()only auto-refetched when the fetcher took no params (!expectsParams), which unconditionally skipped every paginated chunk (their fetcher signature always expectspage/pageSize/cursor). Paginated chunks manage pagination internally and don't need external params to refetch, so they're now included.useAsyncChunkresolves scoped/passed-in chunk instances viauseMemokeyed on the chunk reference, not a one-timeuseStateinitializer — 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
initialParamsfully removed fromUseAsyncChunkOptionsandUseInfiniteAsyncChunkOptions. It has been deprecated since 3.0.0-rc.10 in favor ofparams; both the type and the runtime fallback are now gone. Update any remaininguseAsyncChunk(chunk, { initialParams })call sites touseAsyncChunk(chunk, { params }).
[3.0.6] - 2026-07-01
Fixed
- 403 responses are now tagged
nonRetryableat the HTTP-client layer andfetchData's retry loop respects it — a 403 (permission denied) no longer burns throughretryCountattempts that cannot possibly succeed, since retrying with the same token changes nothing. (This pattern is app-level — see your HTTP client's response interceptor — butasyncChunk's retry logic now checkserror.nonRetryablebefore retrying.)
[3.0.3] - 2026-06-27
Fixed
reload()did not previously reset a paginated chunk'scursor/pagebefore 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
clearOnParamChangeoption onasyncChunkandpaginatedAsyncChunk— whentrue,setParams()immediately clearsdatatonullbefore 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 whenenabledflipsfalse → trueandparamschange in the same render. Previously both theenabledeffect and theparamsKeyeffect firedsetParamssimultaneously, causing two network requests and a potential race condition. Theenabledeffect now signals theparamsKeyeffect 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 returningnullfrom a mutator, which is consistent withdatabeing typedT | null.
[3.0.1] - 2026-06-24
Fixed
- Added
cancel()method toAsyncChunk— cancels any in-flight request and setsloadingtofalseimmediately - Added
isCancelledflag infetchData— prevents cancelled async closures from setting data after cancellation useAsyncChunkhook now callscancel()whenenabledflips fromtruetofalseuseAsyncChunkhook now usessetParams()instead ofreload()whenenabledflips fromfalsetotrueand params are present — fixes fetch not triggering whencurrentParamswas emptysetParams()andreload()now resetisCancelledflag before starting a new fetch
Added
cancel: () => voidtoAsyncChunkinterface
Use case unlocked
- Factory pattern (
createXChunk) now works correctly withenabled: isOpenfor 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 responseThis 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) andinfiniteAsyncChunk(21), including a dedicated cursor-pagination suite covering first-page fetch,nextPagecursor chaining,hasMorederivation,prevPage/goToPageno-op behavior,resetPaginationcursor clearing,setParamscursor reset, and a structural guard ensuringpagenever 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
useAsyncChunknow fetches on mount by default when enabled — previously, a chunk created withenabled: falsethat later became enabled required an explicitfetchOnMount: trueto actually fetch once mounted, which didn't match the expected "fetch automatically unlessenabled: false" behavior.fetchOnMountis now only needed to force a refetch when data already exists.useInfiniteAsyncChunknow forwardsparams— previously only the deprecatedinitialParamsreached the underlying fetcher; reactiveparamspassed by the caller were silently dropped, so search/filter inputs never reached the server on subsequent renders.setParamsresets paginated chunks to page 1 — previously, changing params while on page 3 would fetch "page 3 with new params" instead of starting over. Affects bothpaginatedAsyncChunkandinfiniteAsyncChunk; accumulated data is also cleared on reset whenmode: 'accumulate'.pageSizeno longer leaks into fetch params under that name — Stunk's internal pagination state injectspage/pageSizeautomatically, 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: reactiveenabled— when passed as a function, dependencies are now tracked viatrackDependenciesand the chunk auto-fetches when they changeasyncChunk:setupSideEffects()now called onfalse → trueenabled transition sorefetchIntervalstarts correctly after loginasyncChunk: guard against duplicate intervals and focus listeners on re-initializationasyncChunk:isNextPageFetchflag —accumulatemode now only accumulates onnextPage(); all other fetches (reload, reset, enabled transition) replace data instead of appendinguseAsyncChunk: newenabledoption for React-level reactive fetch controlmutation:invalidatesnow callsresetPagination()on paginated chunks instead ofreload()to prevent stale page state
Exports
- Added
PaginatedParamAsyncChunkto public exports fromstunk/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 dependencystrictmode onChunkConfig— throws or warns on unknown keys inset()in developmentnullis now a valid chunk value (undefinedstill forbidden)ReadOnlyChunk<T>—derive()now returns a proper read-only type at the TypeScript leveltrackDependenciesexported — for building custom reactive primitivesvalidateObjectShapeimproved — no longer warns onundefined → TorT → nulltransitions
Computed (redesigned)
- Auto-tracks dependencies via
.get()calls — no dependency arrays needed - Lazy evaluation with eager recompute when subscribers are active
isDirty()andrecompute()for manual controlpeek()insidecomputed()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, lastFetchedpaginatedAsyncChunk— dedicated page-based pagination, no overload ambiguity (added rc.10); supports cursor-based pagination viacursorMode(added rc.11)infiniteAsyncChunk— accumulate-mode pagination for infinite scroll, page-based or cursor-basedcombineAsyncChunks— unified loading/error/data across multiple async chunkskeyoption — request deduplication, concurrent calls share one in-flight requestkeepPreviousData+isPlaceholderData— no UI flicker on param changesonSuccess/onErrorcallbacksenabledas boolean or() => boolean— dynamic disablingsetParamswithnullclearing individual keys,clearParams()to wipe allrefetchOnWindowFocus,refetchInterval,staleTime,cacheTimeforceCleanup()+ ref-countedcleanup()- Full pagination —
nextPage,prevPage,goToPage,resetPagination - SSR-safe — all
windowaccess guarded
Middleware (stunk/middleware)
history(renamed fromwithHistory) —reset()now clears the history stackskipDuplicates: true— strict equality only;'shallow'— shallow equality for objectspersist(renamed fromwithPersistence) —clearStorage()added,onErrorcalled on type mismatches, array vs object type mismatch detection
React (stunk/react)
useAsyncChunk— rewritten: single effect, Rules of Hooks compliant, exposesisPlaceholderDataandclearParamsuseInfiniteAsyncChunk— stableIntersectionObserver, SSR-safe, correctisFetchingMoreuseDerive,useComputed,useChunkProperty,useChunkValues— removed (useuseChunkValue(computed(...))instead)
Breaking changes from v2
| Change | v2 | v3 |
|---|---|---|
computed() API | computed([deps], fn) | computed(() => fn) |
withHistory | import { withHistory } | import { history } |
withPersistence | import { withPersistence } | import { persist } |
asyncChunk import | stunk | stunk/query |
useDerive / useComputed | available | removed |
subscribe fires on subscribe | yes | no — only on change |
null as chunk value | forbidden | allowed |
v2.8.1 — Stable
Latest stable release. Bundle size: 2.95kB gzipped (core + React).
What's in v2
Core (stunk)
chunk()— atomic state primitive withget(),set(),reset(),destroy(),subscribe(),derive()computed()— derive state from multiple chunks with dependency arrays andisDirty()trackingselect()— read-only derived chunk with optional shallow equality (useShallowEqual)asyncChunk()— async state with built-inloading,error,data, andreloadinfiniteAsyncChunk()— paginated / infinite-scroll async statebatch()— group multiple state updates into a single render cycle
Middleware (stunk/middleware)
logger— logs everyset()callwithHistory()— undo/redo history (undo,redo,canUndo,canRedo,getHistory,clearHistory,maxHistory)withPersistence()— localStorage persistence with customserialize/deserializenonNegativeValidator— throws if a numeric value goes below zero
React (stunk/react)
useChunk— read and write a chunk reactivelyuseChunkValue— read-only subscriptionuseDerive— derive a value from a single chunkuseComputed— compute a value from multiple chunksuseAsyncChunk— async state hookuseInfiniteAsyncChunk— 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() removedRelease History
| Version | Status | Highlights |
|---|---|---|
3.1.0 | ✅ Latest | scoped option, reset(refetch), dedup-by-params fix, subscriber-gated cache eviction, initialParams removed |
3.0.6 | ✅ Stable | 403 tagged nonRetryable, skipped in retry loop |
3.0.3 | ✅ Stable | reload() cursor/page reset fix |
3.0.2 | ✅ Stable | clearOnParamChange, double-fetch fix, mutate type fix |
3.0.1 | ✅ Stable | cancel(), enabled flip fixes |
3.0.0-rc.11 | 🚧 RC | Cursor-based pagination support |
3.0.0-rc.10 | 🚧 RC | paginatedAsyncChunk split, fetchOnMount default, params forwarding fix |
3.0.0-rc.4 – rc.9 | 🚧 RC | Reactive enabled, accumulate-mode fix, invalidates pagination fix |
3.0.0-alpha | 🚧 Alpha | Computed redesign, stunk/query, strict mode, null values |
2.8.1 | ✅ Stable | Latest stable, 2.95kB gzipped |
2.x | ✅ Stable | Full React integration, async, middleware, time travel |
1.x | ⚠️ Deprecated | Early API, no longer supported |