Skip to content

TanStack Query and Primitive Stores

The same checkout flow as TanStack Query and One Fat Store, built the other way: instead of one withPlugins store holding the whole client state, each field gets its own createStore, and a derive store merges them for persistence. Kin Store still holds what the client owns (cart contents, current step, draft promo code); TanStack Query still holds what the server owns (catalog, stock, computed pricing, order history). Full source in examples/checkout-jotai-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 stores

ts
// src/stores.ts
import { createStore, derive } from "@kin-store/core";

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 PERSIST_KEY = "checkout-react-query";

const persistedState = JSON.parse(
  localStorage.getItem(PERSIST_KEY) || "null",
) as CheckoutState | null;

// One store per field, rather than one store for the whole client state.
export const stepStore = createStore<Step>(persistedState?.step || "cart");
export const itemsStore = createStore(persistedState?.items || []);
export const promoCodeStore = createStore(persistedState?.promoCode || null);
export const zipStore = createStore(persistedState?.zip || "");
export const lastOrderIdStore = createStore(
  persistedState?.lastOrderId || null,
);

// derive merges the fields back into one view to persist as a single value.
derive((get) => ({
  step: get(stepStore),
  items: get(itemsStore),
  promoCode: get(promoCodeStore),
  zip: get(zipStore),
  lastOrderId: get(lastOrderIdStore),
})).subscribe((get) => {
  localStorage.setItem(PERSIST_KEY, JSON.stringify(get()));
});

// App logic is plain functions that read and write the primitive stores
// directly — no reducers, no dispatch.
export function setQuantity(productId: string, quantity: number) {
  let v = itemsStore.get();
  v = quantity <= 0
    ? v.filter((i) => i.productId !== productId)
    : v.some((i) => i.productId === productId)
    ? v.map((i) => i.productId === productId ? { ...i, quantity } : i)
    : [...v, { productId, quantity }];

  itemsStore.set(v);
}

export function applyPromoCode(code: string) {
  promoCodeStore.set(code.trim() || null);
}

export function completeOrder(orderId: string) {
  itemsStore.set([]);
  promoCodeStore.set(null);
  zipStore.set("");
  stepStore.set("confirmation");
  lastOrderIdStore.set(orderId);
}

export function startNewOrder() {
  stepStore.set("cart");
  lastOrderIdStore.set(null);
}

The persist plugin doesn't apply here the way it does in the fat-store version: it attaches to a single mutable store via withPlugins, and there isn't one, just five independent stores plus a read-only derive view over them. So persistence is hand-rolled instead: derive produces the merged snapshot, and subscribe writes it to localStorage on every change. The persist plugin's schema versioning, migration, and async-storage support aren't available for free here; they'd need to be written by hand too, if needed.

Reading a field

Because each field already lives in its own store, a component that only cares about one field just subscribes to that one, no selector required:

tsx
// src/App.tsx
import { useStore } from "@kin-store/react";
import { stepStore } from "./stores.ts";

export function App() {
  const step = useStore(stepStore);
  // ...
}

Compare to the fat-store version's useSelector(checkoutStore, (s) => s.step) — the selector's job (narrowing a subscription to one field) is already done by the store boundary itself.

Feeding stores into a query key

Same dependent-query shape as the fat-store version, just reading from three separate stores instead of selecting three fields off of one:

ts
// src/queries/pricing.ts
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useStore } from "@kin-store/react";
import { itemsStore, promoCodeStore, zipStore } from "../stores.ts";
import { calculatePricing } from "../api.ts";

export function useCartPricing() {
  const items = useStore(itemsStore);
  const promoCode = useStore(promoCodeStore);
  const zip = useStore(zipStore);

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

Writing back from a mutation

completeOrder is a plain function, not a dispatched reducer, so a mutation's onSuccess just calls it directly:

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

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

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

MIT License. Comparison