Skip to content

TanStack Query and One Fat Store

A checkout flow that splits state along ownership lines: Kin Store holds what the client owns (cart contents, current step, draft promo code), TanStack Query holds what the server owns (catalog, stock, computed pricing, order history). This variant keeps the client half as one withPlugins store with reducers; see TanStack Query and Primitive Stores for the same app built the other way, one createStore per field. Full source in examples/checkout-redux-style-react-query.

Why split state at all

Putting everything in one store, client and server data alike, means duplicating whatever caching/refetching/invalidation logic a query library already solves, or doing without it. Putting everything in the query library means treating pending user input (an unconfirmed cart, a promo code being typed) as if it were server data with a cache lifetime. Neither fits well. The split used here is: if a page refresh should lose it, it's a query; if it should survive one, it's a store.

The store

ts
// src/store.ts
import { withPlugins } from "@kin-store/core";
import { devtools, persist } from "@kin-store/plugins";

export type CartItem = { productId: string; quantity: number };
export type Step = "cart" | "checkout" | "confirmation";

export type CheckoutState = {
  step: Step;
  items: CartItem[];
  promoCode: string | null;
  zip: string;
  lastOrderId: string | null;
};

const initialState: CheckoutState = {
  step: "cart",
  items: [],
  promoCode: null,
  zip: "",
  lastOrderId: null,
};

export const checkoutStore = withPlugins(initialState)
  .use("persist", persist({ key: "checkout-react-query" }))
  .use(import.meta.env.DEV ? devtools() : {})
  .use({
    reducers: {
      setQuantity(state, productId: string, quantity: number) {
        const items = quantity <= 0
          ? state.items.filter((i) => i.productId !== productId)
          : state.items.some((i) => i.productId === productId)
          ? state.items.map((i) =>
            i.productId === productId ? { ...i, quantity } : i
          )
          : [...state.items, { productId, quantity }];

        return { ...state, items };
      },
      applyPromoCode(state, code: string) {
        return { ...state, promoCode: code.trim() || null };
      },
      setZip(state, zip: string) {
        return { ...state, zip };
      },
      setStep(state, step: Step) {
        return { ...state, step };
      },
      completeOrder(_state, orderId: string) {
        return { ...initialState, step: "confirmation", lastOrderId: orderId };
      },
      startNewOrder() {
        return initialState;
      },
    },
  });

Nothing here is server data — no product list, no stock counts, no computed totals. Just the cart the shopper is building and where they are in the flow, all in one place. It's a plain module-level singleton (a client-only SPA has no per-request isolation concern, unlike the Next.js example), and persist means an abandoned cart is still there if they come back later.

Feeding a store selection into a query key

useCartPricing is a dependent query: its inputs come straight out of Kin Store, and its query key changes whenever they do, so React Query refetches automatically as the cart changes. Pricing math (discounts, shipping thresholds, tax) stays server-computed rather than duplicated on the client:

ts
// src/queries/pricing.ts
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useSelector } from "@kin-store/react";
import { checkoutStore } from "../store.ts";
import { calculatePricing } from "../api.ts";

export function useCartPricing() {
  const items = useSelector(checkoutStore, (s) => s.items);
  const promoCode = useSelector(checkoutStore, (s) => s.promoCode);
  const zip = useSelector(checkoutStore, (s) => s.zip);

  return useQuery({
    queryKey: ["pricing", items, promoCode, zip],
    queryFn: () => calculatePricing({ items, promoCode, zip }),
    enabled: items.length > 0,
    placeholderData: keepPreviousData,
  });
}

Each field needs its own useSelector call, since they all live inside the same state object, one store subscription per slice a component cares about.

Writing back from a mutation

The reverse direction: a mutation's onSuccess updates Kin Store (client state moves to "confirmation") and invalidates a React Query cache (server state refetches) in the same callback:

ts
// src/mutations/submit-order.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { checkoutStore } from "../store.ts";
import { submitOrder } from "../api.ts";

export function useSubmitOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: submitOrder,
    onSuccess: (order) => {
      // Client state moves to "confirmation" and forgets the cart.
      checkoutStore.dispatch.completeOrder(order.id);
      // Server state is invalidated so the order-history list refetches.
      queryClient.invalidateQueries({ queryKey: ["orders"] });
    },
  });
}

Driving the UI off the store

The top-level step selection is what decides which panel renders — no router, no separate page per step:

tsx
// src/App.tsx
import { useSelector } from "@kin-store/react";
import { checkoutStore } from "./store.ts";
import { ProductCatalog } from "./components/ProductCatalog.tsx";
import { CartPanel } from "./components/CartPanel.tsx";
import { CheckoutForm } from "./components/CheckoutForm.tsx";
import { Confirmation } from "./components/Confirmation.tsx";

export function App() {
  const step = useSelector(checkoutStore, (s) => s.step);

  return (
    <div>
      {step === "cart" && (
        <>
          <ProductCatalog />
          <CartPanel />
        </>
      )}
      {step === "checkout" && <CheckoutForm />}
      {step === "confirmation" && <Confirmation />}
    </div>
  );
}

MIT License. Comparison