StunkStunk
Integrations

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?);
TypeDescription
valueTCurrent chunk value (or selected slice)
set(value | updater) => voidUpdate the chunk
reset() => voidReset to initial value
destroy() => voidDestroy 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

OptionTypeDescription
paramsPartial<P>Params to pass to the fetcher. Re-fetches automatically when changed.
enabledbooleanWhether this component should fetch. Defaults to true.
fetchOnMountbooleanForce fetch on mount for param-less chunks (default: false)
onSuccess(data: T) => voidCalled after every successful fetch. Has full React context access.
onError(error: E) => voidCalled 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 consumerasyncChunk()
Only this one componentuseAsyncChunk()

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

TypeDescription
dataT | nullResolved data
loadingbooleantrue while fetching
errorE | nullError from last failed fetch
lastFetchednumber | undefinedTimestamp of last successful fetch
isPlaceholderDatabooleantrue when showing stale data during refetch
reload(params?) => Promise<void>Force refetch
refresh(params?) => Promise<void>Refetch if stale
mutate(mutator) => voidUpdate data directly
reset(refetch?: boolean) => voidReset to initial state. Refetches by default unless reset(false) is passed.
setParams(params) => voidUpdate params and refetch (if chunk accepts params)
clearParams() => voidClear all params and refetch (if chunk accepts params)
paginationPaginationStateCurrent 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

OptionTypeDescription
onSuccess(data: T) => voidCalled after mutation succeeds. Has React context.
onError(error: E) => voidCalled when mutation fails. Has React context.

Return value

TypeDescription
mutate(payload) => Promise<Result>Trigger the mutation
loadingbooleantrue while the mutation is in progress
errorE | nullError from the last failed mutation
dataT | nullData from the last successful mutation
reset() => voidReset 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

OptionTypeDefaultDescription
autoLoadbooleantrueAuto-load next page when sentinel is visible
thresholdnumber1.0Intersection observer threshold
paramsobjectReactive params (excluding page and pageSize). Re-fetches from page 1 when changed.

Summary

HookUse case
useChunkRead + write a chunk
useChunkValueRead-only — also for derived/computed/select
useAsyncChunkAsync state with loading/error/data
useMutationPOST, PUT, DELETE with cache invalidation
useInfiniteAsyncChunkInfinite scroll with auto-load

On this page