React
Official React integration for Stunk — hooks for reading, writing, and subscribing to chunks.
Stunk's React integration is available from stunk/react. It provides hooks that subscribe to chunks and trigger re-renders only when the relevant value changes.
import {
useChunk,
useChunkValue,
useAsyncChunk,
useInfiniteAsyncChunk,
useMutation,
} from "stunk/react";useChunk
The primary hook for reading and writing a chunk. Returns [value, set, reset, destroy].
import { chunk } from "stunk";
import { useChunk } from "stunk/react";
const counter = chunk(0);
function Counter() {
const [count, setCount, resetCount] = useChunk(counter);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={resetCount}>Reset</button>
</div>
);
}With a selector
Pass an optional selector to subscribe to a slice of the value. The component only re-renders when the selected slice changes:
const user = chunk({ name: "Fola", age: 25, role: "admin" });
function UserName() {
const [name, setUser] = useChunk(user, (u) => u.name);
// only re-renders when name changes
}Return value
const [value, set, reset, destroy] = useChunk(chunk, selector?);| Type | Description | |
|---|---|---|
value | T | Current chunk value (or selected slice) |
set | (value | updater) => void | Update the chunk |
reset | () => void | Reset to initial value |
destroy | () => void | Destroy the chunk and clear all subscribers |
useChunkValue
Read-only version of useChunk. Use this when a component only needs to read — no setter needed.
import { useChunkValue } from "stunk/react";
const theme = chunk<"light" | "dark">("light");
function ThemeDisplay() {
const currentTheme = useChunkValue(theme);
return <p>Theme: {currentTheme}</p>;
}Also accepts a selector:
const user = chunk({ name: "Fola", age: 25 });
function UserAge() {
const age = useChunkValue(user, (u) => u.age);
return <p>Age: {age}</p>;
}This is the preferred hook for derived chunks, computed chunks, and selectors:
const price = chunk(100);
const quantity = chunk(3);
const total = computed(() => price.get() * quantity.get());
const discounted = price.derive((p) => p * 0.9);
function Summary() {
const totalValue = useChunkValue(total);
const discountedValue = useChunkValue(discounted);
return (
<p>
Total: ${totalValue} (discounted: ${discountedValue})
</p>
);
}useAsyncChunk
Subscribes to an async chunk and returns its full state with all async methods.
import { asyncChunk } from "stunk/query";
import { useAsyncChunk } from "stunk/react";
const postsChunk = asyncChunk(async () => {
const res = await fetch("/api/posts");
return res.json() as Promise<Post[]>;
});
function PostList() {
const { data, loading, error, reload } = useAsyncChunk(postsChunk);
if (loading) return <p>Loading...</p>;
if (error)
return (
<p>
Error: {error.message} <button onClick={reload}>Retry</button>
</p>
);
return (
<ul>
{data?.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}Options
| Option | Type | Description |
|---|---|---|
params | Partial<P> | Params to pass to the fetcher. Re-fetches automatically when changed. |
enabled | boolean | Whether this component should fetch. Defaults to true. |
fetchOnMount | boolean | Force fetch on mount for param-less chunks (default: false) |
onSuccess | (data: T) => void | Called after every successful fetch. Has full React context access. |
onError | (error: E) => void | Called when a fetch fails. Has full React context access. |
Where to put enabled — chunk level vs hook level
enabled exists in two places and they serve different purposes. Using the wrong one leads to unexpected fetches or missed fetches.
enabled in asyncChunk() controls whether the chunk is allowed to fetch at all — globally, for every consumer. Use it when the condition is app-wide and the same regardless of which component is using the chunk, or when the condition depends on another chunk (since chunk-level enabled supports reactive tracking).
// ✅ Auth guard — same condition everywhere, depends on another chunk
export const jobMetricsChunk = asyncChunk(() => api.getJobMetrics(), {
enabled: () => !!authStore.accessToken.get(),
});enabled in useAsyncChunk() controls whether this specific component instance should fetch right now — based on local React state or props. Use it when the condition is local to a component, varies between components, or involves React state.
// ✅ Modal guard — local to this component, depends on React state
const { data } = useAsyncChunk(companiesChunk, {
enabled: isOpen && !!jobRef,
params: { ref: jobRef },
});The rule:
| The condition depends on… | Put enabled in… |
|---|---|
| Another chunk (auth, role, feature) | asyncChunk() |
| React state or props (isOpen, id) | useAsyncChunk() |
| The same logic in every consumer | asyncChunk() |
| Only this one component | useAsyncChunk() |
Do not use chunk-level enabled and hook-level enabled to express the same
condition simultaneously — they are additive, not equivalent, and can cause
double-fetch races when both flip at the same time.
Hook-level callbacks
onSuccess and onError at the hook level have full access to React context — navigate, setState, anything React. This is the correct place for navigation after fetch, unlike asyncChunk-level callbacks which are defined at module scope and have no React context.
function ProfilePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const { data, loading } = useAsyncChunk(profileChunk, {
onSuccess: (data) => {
if (!data) return navigate("/login");
setName(data.name);
},
onError: () => navigate("/error"),
});
}Return value
| Type | Description | |
|---|---|---|
data | T | null | Resolved data |
loading | boolean | true while fetching |
error | E | null | Error from last failed fetch |
lastFetched | number | undefined | Timestamp of last successful fetch |
isPlaceholderData | boolean | true when showing stale data during refetch |
reload | (params?) => Promise<void> | Force refetch |
refresh | (params?) => Promise<void> | Refetch if stale |
mutate | (mutator) => void | Update data directly |
reset | (refetch?: boolean) => void | Reset to initial state. Refetches by default unless reset(false) is passed. |
setParams | (params) => void | Update params and refetch (if chunk accepts params) |
clearParams | () => void | Clear all params and refetch (if chunk accepts params) |
pagination | PaginationState | Current pagination state (if paginated) |
nextPage | () => Promise<void> | Load next page (if paginated) |
prevPage | () => Promise<void> | Load previous page (if paginated) |
goToPage | (page) => Promise<void> | Jump to a page (if paginated) |
resetPagination | () => Promise<void> | Reset to page 1 (if paginated) |
useAsyncChunk automatically calls cleanup() when the component unmounts —
clearing any polling intervals or cache timers.
useMutation
Subscribes to a mutation and returns mutate, loading, error, and data.
import { mutation } from "stunk/query";
import { useMutation } from "stunk/react";
const createPostMutation = mutation(
async (payload: { title: string }) => apiCreatePost(payload),
{ invalidates: [postsChunk] },
);
function CreatePost() {
const { mutate, loading, error } = useMutation(createPostMutation);
async function handleSubmit(title: string) {
const { error } = await mutate({ title });
if (!error) toast.success("Post created");
}
return (
<button onClick={() => handleSubmit("My post")} disabled={loading}>
{loading ? "Creating..." : "Create"}
</button>
);
}Hook-level callbacks
Like useAsyncChunk, useMutation supports onSuccess and onError at the hook level for navigation and React state updates:
function CreateTenant() {
const navigate = useNavigate();
const { mutate, loading } = useMutation(createTenantMutation, {
onSuccess: (tenant) => {
toast.success("Tenant added");
navigate(`/tenants/${tenant.id}`);
},
onError: (err) => toast.error(err.message),
});
}Options
| Option | Type | Description |
|---|---|---|
onSuccess | (data: T) => void | Called after mutation succeeds. Has React context. |
onError | (error: E) => void | Called when mutation fails. Has React context. |
Return value
| Type | Description | |
|---|---|---|
mutate | (payload) => Promise<Result> | Trigger the mutation |
loading | boolean | true while the mutation is in progress |
error | E | null | Error from the last failed mutation |
data | T | null | Data from the last successful mutation |
reset | () => void | Reset state to idle |
useInfiniteAsyncChunk
Wraps useAsyncChunk for infinite scroll. Attach observerTarget to a sentinel element at the bottom of your list — the hook handles intersection observation automatically.
import { infiniteAsyncChunk } from "stunk/query";
import { useInfiniteAsyncChunk } from "stunk/react";
const postsChunk = infiniteAsyncChunk(
async ({ page, pageSize }) => {
const res = await fetch(`/api/posts?page=${page}&limit=${pageSize}`);
const json = await res.json();
return { data: json.posts, hasMore: json.hasMore };
},
{ pageSize: 20 },
);
function PostFeed() {
const {
data,
loading,
error,
hasMore,
isFetchingMore,
loadMore,
observerTarget,
} = useInfiniteAsyncChunk(postsChunk);
if (loading && !data?.length) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<ul>
{data?.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
<div ref={observerTarget} />
{isFetchingMore && <p>Loading more...</p>}
{!hasMore && <p>You've reached the end.</p>}
</div>
);
}Options
Options
| Option | Type | Default | Description |
|---|---|---|---|
autoLoad | boolean | true | Auto-load next page when sentinel is visible |
threshold | number | 1.0 | Intersection observer threshold |
params | object | — | Reactive params (excluding page and pageSize). Re-fetches from page 1 when changed. |
Summary
| Hook | Use case |
|---|---|
useChunk | Read + write a chunk |
useChunkValue | Read-only — also for derived/computed/select |
useAsyncChunk | Async state with loading/error/data |
useMutation | POST, PUT, DELETE with cache invalidation |
useInfiniteAsyncChunk | Infinite scroll with auto-load |