State Management: From ViewModels to Stores
For .NET engineers who know: MVVM with ViewModels (WPF/MAUI), Blazor component state and
@bind, server-side session, scoped DI services as state containers You’ll learn: How state is managed in React and Vue — from local component state up through global stores — and when each layer is the right tool Time: 15-20 minutes
The .NET Way (What You Already Know)
In .NET, state has a home that is usually determined at design time. In WPF and MAUI, ViewModels hold UI state: bound properties, command handlers, validation state. The ViewModel is instantiated by the DI container (or by the View directly), and two-way binding keeps the UI synchronized. The framework knows where state lives because the binding system enforces it.
// WPF/MAUI ViewModel — state is explicit, framework-managed
public class OrderViewModel : ObservableObject
{
private Order? _selectedOrder;
public Order? SelectedOrder
{
get => _selectedOrder;
set => SetProperty(ref _selectedOrder, value);
}
private bool _isLoading;
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
[RelayCommand]
private async Task LoadOrderAsync(int id)
{
IsLoading = true;
SelectedOrder = await _orderService.GetAsync(id);
IsLoading = false;
}
}
In Blazor, component state lives in fields or properties inside the component class. Child components receive state through parameters, and signal changes back to parents through EventCallback. For cross-component state, you inject a scoped service — effectively a manually implemented observable store:
// Blazor — service as a cross-component state container
public class CartState
{
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public event Action? OnChange;
public void AddItem(CartItem item)
{
_items.Add(item);
NotifyStateChanged();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
In ASP.NET applications you also have server-side session, TempData, and ViewBag for short-lived cross-request state — but those patterns don’t translate to the JS world at all. The server holds no per-user state between requests; everything meaningful lives in the client.
The Modern JS/TS Way
Layer 1: Local Component State
The closest equivalent to a Blazor component’s fields is local component state. In React it is useState; in Vue 3 it is ref and reactive.
// React — useState for local state
import { useState } from "react";
interface Order {
id: number;
total: number;
status: "pending" | "fulfilled" | "cancelled";
}
function OrderCard({ orderId }: { orderId: number }) {
const [order, setOrder] = useState<Order | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
async function loadOrder() {
setIsLoading(true);
setError(null);
try {
const data = await orderService.get(orderId);
setOrder(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"));
} finally {
setIsLoading(false);
}
}
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!order) return <button onClick={loadOrder}>Load Order</button>;
return <div>{order.id}: {order.total}</div>;
}
// Vue 3 — ref and reactive for local state
import { ref, reactive } from "vue";
interface Order {
id: number;
total: number;
status: "pending" | "fulfilled" | "cancelled";
}
// In a <script setup> block:
const order = ref<Order | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
async function loadOrder(orderId: number) {
isLoading.value = true;
error.value = null;
try {
order.value = await orderService.get(orderId);
} catch (err) {
error.value = err instanceof Error ? err : new Error("Unknown error");
} finally {
isLoading.value = false;
}
}
Notice isLoading, error, and data managed together — this is the manual version of a pattern that TanStack Query handles automatically. You will write this pattern repeatedly until you reach for TanStack Query, at which point you will delete most of it.
Layer 2: Lifting State Up
When two sibling components need to share state, the state moves to their common parent. This is identical to what happens in MVVM when a shared ViewModel serves multiple views.
// React — parent holds shared state, passes down via props and callbacks
function OrderDashboard() {
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
return (
<div>
{/* OrderList signals which order was selected */}
<OrderList
onSelect={setSelectedOrderId}
selectedId={selectedOrderId}
/>
{/* OrderDetail renders the selected order */}
{selectedOrderId && (
<OrderDetail orderId={selectedOrderId} />
)}
</div>
);
}
This pattern works well for shallow component trees. When the state needs to travel through several intermediate layers that do not themselves use it — called “prop drilling” — it becomes painful. That is when you reach for Context.
Layer 3: React Context API (Scoped DI for State)
React Context is the closest analogue to Blazor’s scoped service injection. A provider component wraps a subtree and makes state available to any descendant without threading it through props manually.
// React Context — like a scoped service in Blazor
import { createContext, useContext, useState, ReactNode } from "react";
interface User {
id: number;
name: string;
role: "admin" | "viewer";
}
interface AuthContextValue {
user: User | null;
login: (credentials: { email: string; password: string }) => Promise<void>;
logout: () => void;
}
// Create the context with a sensible default
const AuthContext = createContext<AuthContextValue | null>(null);
// Custom hook — throw early if used outside the provider
function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside an AuthProvider");
}
return ctx;
}
// Provider component — wraps the subtree that needs auth state
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
async function login(credentials: { email: string; password: string }) {
const loggedInUser = await authService.login(credentials);
setUser(loggedInUser);
}
function logout() {
setUser(null);
authService.clearSession();
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Any descendant can consume it
function UserMenu() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>Sign out</button>
</div>
);
}
Context has a significant performance characteristic that trips up .NET engineers: every component that calls useContext(AuthContext) re-renders when any value in the context changes. If your context holds both user and cartItems and the cart changes, every component reading the context re-renders — including those that only need user.
The fix is to split contexts by update frequency and cohesion, or to use a dedicated store library. Do not try to put everything in one context.
Layer 4: Zustand (Recommended for React)
Zustand is a lightweight state management library that avoids the Context re-render problem. Each component subscribes to only the specific slice of state it uses — like connecting a specific column from a shared DataTable, not the whole table.
// Zustand store — recommended for React global state
import { create } from "zustand";
interface CartItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
interface CartStore {
items: CartItem[];
isOpen: boolean;
addItem: (item: CartItem) => void;
removeItem: (productId: number) => void;
updateQuantity: (productId: number, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
get total(): number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
isOpen: false,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.productId === item.productId);
if (existing) {
return {
items: state.items.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((i) => i.productId !== productId),
})),
updateQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
get total() {
return get().items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
},
}));
// Components subscribe only to what they use
function CartBadge() {
// Re-renders only when items.length changes, not on every cart update
const count = useCartStore((state) => state.items.length);
if (count === 0) return null;
return <span className="badge">{count}</span>;
}
function CartTotal() {
// Re-renders only when total changes
const total = useCartStore((state) => state.total);
return <div>Total: ${total.toFixed(2)}</div>;
}
The selector pattern (useCartStore(state => state.items.length)) is how Zustand avoids unnecessary re-renders. The component only re-renders when that specific derived value changes. This is what Context cannot do without significant additional tooling.
Layer 4 (Vue): Pinia (Official Vue Store)
Pinia is the official Vue 3 state management library, replacing Vuex. It looks and feels like a typed ViewModel:
// Pinia store — Vue's equivalent of a typed ViewModel
import { defineStore } from "pinia";
import { ref, computed } from "vue";
interface CartItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
export const useCartStore = defineStore("cart", () => {
// State
const items = ref<CartItem[]>([]);
const isOpen = ref(false);
// Computed (like ViewModel properties)
const total = computed(() =>
items.value.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
)
);
const itemCount = computed(() => items.value.length);
// Actions (like ViewModel commands)
function addItem(item: CartItem) {
const existing = items.value.find((i) => i.productId === item.productId);
if (existing) {
existing.quantity += item.quantity;
} else {
items.value.push(item);
}
}
function removeItem(productId: number) {
items.value = items.value.filter((i) => i.productId !== productId);
}
function clearCart() {
items.value = [];
}
function toggleCart() {
isOpen.value = !isOpen.value;
}
return { items, isOpen, total, itemCount, addItem, removeItem, clearCart, toggleCart };
});
// In a Vue component with <script setup>:
// import { useCartStore } from '@/stores/cart'
// const cart = useCartStore()
// cart.addItem({ ... })
// cart.total // reactive computed value
Pinia stores are typed, DevTools-integrated, and SSR-compatible. They support Vuex-style options API if you prefer, but the composition API form shown above is the modern convention and reads very naturally for .NET engineers familiar with ViewModels.
Layer 5: Redux (Legacy — Know It for Reading Code)
Redux is the pattern you will encounter most often in existing React codebases. It is not the recommended choice for new projects, but you will read it and you need to recognize it.
The Redux pattern: a single immutable global state tree, updated by dispatching actions, transformed by pure reducer functions.
// Redux Toolkit — the modern form (RTK), not the old verbose form
import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit";
interface CartItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
}
const cartSlice = createSlice({
name: "cart",
initialState: { items: [], isOpen: false } as CartState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
// RTK uses Immer under the hood — mutable syntax, immutable result
const existing = state.items.find(
(i) => i.productId === action.payload.productId
);
if (existing) {
existing.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
},
removeItem(state, action: PayloadAction<number>) {
state.items = state.items.filter(
(i) => i.productId !== action.payload
);
},
clearCart(state) {
state.items = [];
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
const store = configureStore({ reducer: { cart: cartSlice.reducer } });
// Component usage
import { useSelector, useDispatch } from "react-redux";
function CartBadge() {
const count = useSelector((state: RootState) => state.cart.items.length);
const dispatch = useDispatch();
// dispatch(addItem({ ... }))
return <span>{count}</span>;
}
Redux’s verbosity exists for a reason: the strict unidirectional data flow makes large-scale applications easier to debug. Redux DevTools can replay every action and show you exactly how state evolved. For teams of 10+ working on complex apps, that traceability has genuine value. For most apps, it is overhead.
Layer 6: TanStack Query (Server State — the Game-Changer)
This is the most important concept in this article. The single biggest mistake teams make is putting server data in global stores.
The mental model shift: server state is not the same as UI state. Server state lives on the server. What you have in the client is a cache of server data. Caches have different requirements than UI state: they go stale, they need to be invalidated, they need to be re-fetched, they need optimistic updates.
TanStack Query (formerly React Query) treats server state as exactly that — a cache — and handles all of it automatically.
// Without TanStack Query — the manual approach you would write in .NET style
function OrderList({ userId }: { userId: number }) {
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
orderService
.getByUser(userId)
.then(setOrders)
.catch(setError)
.finally(() => setIsLoading(false));
}, [userId]);
// ... 30 more lines for pagination, refetch on focus, cache invalidation
}
// With TanStack Query — 3 lines instead of 30, plus automatic caching
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function OrderList({ userId }: { userId: number }) {
const {
data: orders,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["orders", userId], // cache key — same key = same cache
queryFn: () => orderService.getByUser(userId),
staleTime: 30_000, // consider fresh for 30 seconds
refetchOnWindowFocus: true, // re-fetch when tab regains focus
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading orders</div>;
return (
<ul>
{orders?.map((order) => (
<li key={order.id}>{order.id}: ${order.total}</li>
))}
</ul>
);
}
// Mutations with automatic cache invalidation
function CreateOrderForm({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const createOrder = useMutation({
mutationFn: (data: CreateOrderInput) => orderService.create(data),
onSuccess: () => {
// Invalidate the orders cache — next render will re-fetch
queryClient.invalidateQueries({ queryKey: ["orders", userId] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
createOrder.mutate({ userId, items: [] });
}}>
<button type="submit" disabled={createOrder.isPending}>
{createOrder.isPending ? "Creating..." : "Create Order"}
</button>
</form>
);
}
TanStack Query gives you: automatic caching, background re-fetching, stale-while-revalidate, loading and error states, request deduplication (if two components ask for the same query key simultaneously, only one network request fires), pagination helpers, and infinite scroll support.
The Vue equivalent is @tanstack/vue-query, with identical semantics and a composition-style API.
When to Use What
The rule is simpler than you might expect:
- Server data (anything from an API): TanStack Query
- Form state: keep it local, or use React Hook Form / VeeValidate for complex forms
- Ephemeral UI state (modal open, selected tab, accordion open): local
useState/ref - Shared UI state that multiple components across the tree need (current user, theme, cart, notification count): Zustand (React) or Pinia (Vue)
- Do not use global stores for server data. TanStack Query is the store for server data.
The most overused pattern in React: fetching data in useEffect, storing it in global Redux state, selecting it in every component that needs it. TanStack Query replaces this entirely.
Key Differences
| .NET Pattern | JS/TS Equivalent | Notes |
|---|---|---|
| WPF/MAUI ViewModel | Zustand store / Pinia store | JS stores are not bound to specific views |
| Blazor component fields | useState (React) / ref (Vue) | Re-renders on every change |
Blazor EventCallback | Callback props / emits | Parent passes function down; child calls it |
| Blazor scoped service | React Context | Context re-renders all consumers on change |
ObservableObject.SetProperty | Zustand set, Pinia direct mutation | Reactivity is tracked by the framework |
INotifyPropertyChanged | Built into React state / Vue refs | No interface to implement |
async ViewModel load command | TanStack Query useQuery | Caching, loading states, and re-fetch included |
HttpClient + store | TanStack Query | Do not store API data in Redux/Zustand |
| Server-side Session | Not applicable | Client manages its own state entirely |
Gotchas for .NET Engineers
Gotcha 1: Mutating State Directly Does Nothing in React
React detects state changes by reference equality. If you mutate an object in place, React sees the same reference and skips the re-render.
// BROKEN — mutating the existing array; React sees the same reference
function addOrder(newOrder: Order) {
orders.push(newOrder); // orders is still the same array reference
setOrders(orders); // React: "same array as before, skip re-render"
}
// CORRECT — new array, new reference, React re-renders
function addOrder(newOrder: Order) {
setOrders([...orders, newOrder]);
// Or: setOrders(prev => [...prev, newOrder])
}
// BROKEN — mutating a nested object
function updateOrderStatus(id: number, status: string) {
const order = orders.find(o => o.id === id);
if (order) {
order.status = status; // direct mutation
setOrders(orders); // same reference, no re-render
}
}
// CORRECT — produce a new array with a new object for the changed item
function updateOrderStatus(id: number, status: string) {
setOrders(orders.map(o => o.id === id ? { ...o, status } : o));
}
Vue 3 uses a Proxy-based reactivity system (ref and reactive) that does track direct mutations — Vue is closer to WPF/MAUI in this respect. In React, always return new objects and arrays. Redux Toolkit’s reducers use Immer internally, which lets you write mutating syntax that is converted to immutable updates behind the scenes.
Gotcha 2: useEffect Is Not OnInitialized — It Re-Runs
.NET engineers reach for useEffect as a lifecycle hook equivalent to OnInitialized in Blazor or the ViewModel constructor. The dependency array controls when it runs, and this is frequently misconfigured.
// BROKEN — missing userId in dependency array
// Fetches once on mount, never re-fetches when userId changes
useEffect(() => {
fetchOrders(userId).then(setOrders);
}, []); // empty array = run once only
// BROKEN — no dependency array at all
// Runs after every single render — infinite loop if setOrders triggers a render
useEffect(() => {
fetchOrders(userId).then(setOrders);
});
// CORRECT — re-fetches whenever userId changes
useEffect(() => {
let cancelled = false;
fetchOrders(userId).then(data => {
if (!cancelled) setOrders(data);
});
// Cleanup function runs before the next effect and on unmount
return () => { cancelled = true; };
}, [userId]);
The cleanup function is important: if userId changes while a fetch is in-flight, the stale fetch should not update state. This is the pattern TanStack Query handles automatically. If you find yourself writing useEffect for data fetching with cleanup, cancellation, and error handling, reach for TanStack Query instead.
Gotcha 3: Context Does Not Replace a Store — It Has Different Performance Characteristics
A common pattern seen in codebases that avoided Redux “because it’s too complex” is one enormous Context provider containing all application state. This creates a performance problem that is hard to diagnose.
// PROBLEMATIC — one big context causes every consumer to re-render
// on every state change, even unrelated ones
const AppContext = createContext<{
user: User | null;
cart: CartItem[];
theme: "light" | "dark";
notifications: Notification[];
selectedOrderId: number | null;
}>(...);
// When notifications change, UserMenu re-renders even though it only uses user.
// When cart changes, ThemeToggle re-renders even though it only uses theme.
function UserMenu() {
const { user } = useContext(AppContext); // re-renders on every context change
return <div>{user?.name}</div>;
}
// CORRECT — split by update domain; or use Zustand with selectors
const UserContext = createContext<User | null>(null);
const CartContext = createContext<CartStore | null>(null);
const ThemeContext = createContext<"light" | "dark">("light");
// Each context updates independently; consumers only re-render
// when their specific context changes
If you need fine-grained subscriptions from a single state object, use Zustand. Its selector-based subscription model was designed for exactly this problem.
Gotcha 4: Global State Is Often the Wrong Tool for Server Data
The .NET pattern of loading data in a service, caching it, and pushing updates to subscribers via events works well on the server. Replicating it in the client with Redux leads to stores full of users: User[], orders: Order[], and complex loading/error flag management — all of which TanStack Query handles automatically and correctly.
// Redux approach — you're reinventing a cache
const usersSlice = createSlice({
name: "users",
initialState: {
entities: {} as Record<number, User>,
loadingIds: [] as number[],
errorIds: [] as number[],
},
reducers: {
fetchStart(state, action: PayloadAction<number>) {
state.loadingIds.push(action.payload);
},
fetchSuccess(state, action: PayloadAction<User>) {
state.entities[action.payload.id] = action.payload;
state.loadingIds = state.loadingIds.filter(id => id !== action.payload.id);
},
// ... fetchError, invalidate, etc.
},
});
// TanStack Query approach — the cache is handled for you
function useUser(userId: number) {
return useQuery({
queryKey: ["users", userId],
queryFn: () => userService.getById(userId),
staleTime: 60_000,
});
}
// Loading state, error state, caching, deduplication, background refresh: included
The rule of thumb: if the data lives on a server and needs to be fetched, it belongs in TanStack Query. If it is purely client-side state (user preferences not yet saved, UI state, session-only selections), it belongs in a store or local state.
Gotcha 5: useState Updates Are Asynchronous
In Blazor, setting a field and calling StateHasChanged() synchronously queues a re-render. In React, setState calls are batched and asynchronous within event handlers — the new state is not immediately visible.
// BROKEN — reading state immediately after setting it
function handleSubmit() {
setCount(count + 1);
console.log(count); // Logs the OLD value — state hasn't updated yet
setCount(count + 1); // This is count + 1, same as above
setCount(count + 1); // Also count + 1, not count + 3
}
// CORRECT — use the functional form when new state depends on old state
function handleSubmit() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// Now all three increments apply correctly
}
// CORRECT — read state in the render function, not after setting it
// React will re-render the component after state updates
React 18 batches state updates across async boundaries too (automatic batching), so multiple setState calls in an event handler or after await result in a single re-render.
Hands-On Exercise
Build a simple order management dashboard that exercises each layer of state management.
Setup:
// types.ts
export interface Order {
id: number;
customerId: number;
total: number;
status: "pending" | "processing" | "fulfilled" | "cancelled";
createdAt: string;
}
// Simulated API (replace with real fetch calls)
export const orderApi = {
getAll: async (): Promise<Order[]> => {
await new Promise(r => setTimeout(r, 300));
return [
{ id: 1, customerId: 1, total: 99.99, status: "pending", createdAt: "2026-02-18" },
{ id: 2, customerId: 2, total: 249.50, status: "processing", createdAt: "2026-02-17" },
{ id: 3, customerId: 1, total: 49.00, status: "fulfilled", createdAt: "2026-02-16" },
];
},
updateStatus: async (id: number, status: Order["status"]): Promise<Order> => {
await new Promise(r => setTimeout(r, 200));
return { id, customerId: 1, total: 99.99, status, createdAt: "2026-02-18" };
},
};
Exercise 1 — TanStack Query for server state:
// Set up TanStack Query in App.tsx:
// import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// const queryClient = new QueryClient();
// Wrap your app: <QueryClientProvider client={queryClient}>
function OrderList() {
// TODO: Use useQuery to fetch orders from orderApi.getAll()
// Show loading state, error state, and the list of orders
// Bonus: Add a "Refetch" button that calls refetch()
}
function UpdateStatusButton({ order }: { order: Order }) {
const queryClient = useQueryClient();
// TODO: Use useMutation to call orderApi.updateStatus()
// On success, invalidate the ["orders"] query key
// Show a loading state on the button while the mutation is pending
}
Exercise 2 — Zustand for UI state:
// TODO: Create a Zustand store for dashboard UI state
// It should track:
// - selectedOrderId: number | null
// - filterStatus: Order["status"] | "all"
// - isSidebarOpen: boolean
//
// Create three components that each subscribe to only the slice they need:
// - OrderFilter: reads filterStatus, updates it via a select element
// - SidebarToggle: reads isSidebarOpen, toggles it
// - OrderDetail: reads selectedOrderId, displays the selected order's details
Exercise 3 — Context for auth:
// TODO: Create an AuthContext that provides:
// - currentUser: { id: number; name: string; role: "admin" | "viewer" } | null
// - login(userId: number): void (just set a mock user, no real auth)
// - logout(): void
//
// Add a guard: if the user's role is "viewer", the UpdateStatusButton should be disabled
// The guard should read from AuthContext, not from props
Quick Reference
| Scenario | React | Vue | Notes |
|---|---|---|---|
| Local component state | useState | ref / reactive | First choice for any state |
| Derived state from local state | useMemo | computed | Recalculates only when inputs change |
| Shared state between siblings | Lift to parent | Lift to parent | Pass via props and callbacks/emits |
| Cross-tree shared UI state | Zustand | Pinia | Avoid Context for high-frequency updates |
| Authentication / current user | Context or Zustand | Pinia | Low-frequency changes; Context is fine |
| Server data (API calls) | TanStack Query | TanStack Vue Query | Not a global store — it is a cache |
| Form state | React Hook Form | VeeValidate | Do not use global store for form fields |
| URL-derived state | useSearchParams | useRoute / useRouter | Shareable, bookmarkable state |
| Persisted client state | Zustand + persist middleware | Pinia + pinia-plugin-persistedstate | Syncs to localStorage automatically |
| Global store (legacy) | Redux Toolkit | Pinia | Redux for existing codebases only |
| Optimistic updates | TanStack Query onMutate | TanStack Vue Query | Update cache before server confirms |
Further Reading
- TanStack Query Documentation — The primary reference. The “Guides and Concepts” section, particularly “Query Invalidation” and “Optimistic Updates”, covers 90% of real-world usage.
- Zustand GitHub — The README is short and complete. Read the entire thing.
- Pinia Documentation — Official Vue store documentation. The “Core Concepts” section maps directly to ViewModel concepts.
- Thinking in React — The official React guide on identifying where state should live. Short and precise.
- TkDodo’s Blog: Practical React Query — The best non-official resource on TanStack Query patterns. The series on “React Query and TypeScript” is particularly relevant.