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
// 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:
// 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:
// 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:
// 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"] });
},
});
}