Introduction to State Management in Vite
Vite has rapidly become one of the most popular build tools for modern web development, thanks to its lightning-fast dev server, native ES modules support, and rich plugin ecosystem. But while Vite handles bundling, hot module replacement (HMR), and asset optimization, it does not prescribe how you should manage application state. That decision is left to you โ and it is one of the most consequential architectural choices you will make in any non-trivial application.
State management refers to the patterns and tools used to store, update, and share data across an application. In a Vite project, which often pairs with frameworks like React, Vue, Svelte, or Solid, the way you handle state determines how predictable, scalable, and maintainable your app becomes. This tutorial explores the core concepts of state management, walks through practical patterns, and compares the most popular libraries you can integrate into a Vite-based workflow.
What Is State Management?
At its simplest, "state" is any data that your application needs to remember between user interactions. This includes form inputs, fetched API responses, authentication tokens, UI toggles, theme preferences, and more. State management is the discipline of organizing this data so that it can be read and updated in a consistent, predictable way.
State generally falls into several categories:
- Local component state: Data used by a single component, such as a dropdown's open/closed flag.
- Shared state: Data needed by multiple components, like the current logged-in user.
- Server state: Data fetched from an API, including loading and error indicators.
- URL state: Data encoded in the route, such as filters or pagination parameters.
- Persistent state: Data that survives page reloads, typically stored in localStorage or IndexedDB.
Effective state management means choosing the right home for each kind of state and establishing clear rules for how it flows through your app.
Why State Management Matters in Vite Projects
Because Vite is framework-agnostic, it works equally well with React, Vue, Svelte, Solid, Preact, and others. Each of these frameworks has its own built-in reactivity model, but they all share a common challenge: as applications grow, passing data through deeply nested component trees becomes painful. This problem, often called "prop drilling," leads to brittle code where intermediate components receive props they do not actually use.
A dedicated state management strategy provides several benefits:
- Predictability: Centralized stores make it easier to trace why the UI changed.
- Testability: Pure update functions and isolated stores are simpler to unit test.
- Performance: Fine-grained subscriptions prevent unnecessary re-renders.
- Developer experience: Time-travel debugging and devtools make inspecting state changes intuitive.
- Scalability: Clear boundaries between local and global state keep large codebases organized.
Setting Up a Vite Project
Before diving into state management libraries, let us create a fresh Vite project. The examples in this tutorial will use React, but the concepts translate to other frameworks supported by Vite.
# Scaffold a new Vite + React + TypeScript project
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
With the project running, you now have a blank canvas. Let us explore state management patterns, starting from the simplest approach and progressing to full-featured libraries.
Pattern 1: Local State with Built-in Hooks
The first and most lightweight approach is to use the framework's built-in primitives. In React, this means useState and useReducer. This pattern is ideal for state that only one component (and perhaps its immediate children) needs.
Using useState for Simple Local State
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
export default Counter;
Using useReducer for Complex Local State
When state transitions become more involved, useReducer offers a structured alternative. It centralizes update logic in a reducer function, making the component easier to reason about.
import { useReducer } from 'react';
type Todo = { id: number; text: string; done: boolean };
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: number }
| { type: 'remove'; id: number };
function todosReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle':
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case 'remove':
return state.filter((t) => t.id !== action.id);
default:
return state;
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todosReducer, []);
return (
<div>
<button onClick={() => dispatch({ type: 'add', text: 'New task' })}>
Add Task
</button>
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => dispatch({ type: 'toggle', id: todo.id })}>
{todo.done ? 'โ' : 'โ'} {todo.text}
<button onClick={() => dispatch({ type: 'remove', id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
This pattern works well for isolated features, but it does not solve the problem of sharing state across distant parts of the component tree.
Pattern 2: Context API for Shared State
React's Context API lets you broadcast state to any descendant component without prop drilling. It is built into React, requires no extra dependencies, and is a great fit for medium-complexity applications.
import { createContext, useContext, useState, ReactNode } from 'react';
type AuthState = {
user: string | null;
login: (username: string) => void;
logout: () => void;
};
const AuthContext = createContext<AuthState | undefined>(undefined);
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<string | null>(null);
const login = (username: string) => setUser(username);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
function Navbar() {
const { user, logout } = useAuth();
return (
<nav>
{user ? (
<>
<span>Welcome, {user}</span>
<button onClick={logout}>Log out</button>
</>
) : (
<span>Please log in</span>
)}
</nav>
);
}
function App() {
const { login } = useAuth();
return (
<AuthProvider>
<Navbar />
<button onClick={() => login('alice')}>Log in as Alice</button>
</AuthProvider>
);
}
export default App;
While Context is convenient, it has a known limitation: any consumer re-renders whenever the context value changes. For high-frequency updates or large state objects, this can cause performance bottlenecks. That is where dedicated state management libraries shine.
Pattern 3: Zustand for Lightweight Global State
Zustand is a minimalist state management library that has gained enormous popularity in the Vite and React ecosystems. It provides a small, hook-based API, avoids boilerplate, and supports fine-grained subscriptions so only the components that need specific slices of state re-render.
Installing Zustand
npm install zustand
Creating a Store
import { create } from 'zustand';
interface BearStore {
bears: number;
addBear: () => void;
removeBear: () => void;
reset: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
removeBear: () => set((state) => ({ bears: state.bears - 1 })),
reset: () => set({ bears: 0 }),
}));
export default useBearStore;
Consuming the Store in Components
import useBearStore from './store';
function BearCounter() {
// Subscribe only to the bears value
const bears = useBearStore((state) => state.bears);
return <h1>{bears} bears around here</h1>;
}
function Controls() {
const addBear = useBearStore((state) => state.addBear);
const removeBear = useBearStore((state) => state.removeBear);
const reset = useBearStore((state) => state.reset);
return (
<div>
<button onClick={addBear}>Add Bear</button>
<button onClick={removeBear}>Remove Bear</button>
<button onClick={reset}>Reset</button>
</div>
);
}
function App() {
return (
<>
<BearCounter />
<Controls />
</>
);
}
export default App;
Zustand also supports middleware for persistence, devtools integration, and immutability. Here is an example of persisting state to localStorage:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{ name: 'settings-storage' }
)
);
export default useSettingsStore;
Pattern 4: Redux Toolkit for Structured, Scalable State
For large-scale applications with complex state interactions, Redux Toolkit (RTK) remains a robust choice. It reduces the historical boilerplate of Redux through helper functions like createSlice and configureStore, and it ships with excellent devtools for time-travel debugging.
Installing Redux Toolkit and React-Redux
npm install @reduxjs/toolkit react-redux
Defining a Slice
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
}
const initialState: CartState = {
items: [],
totalQuantity: 0,
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
existing.quantity++;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.totalQuantity++;
},
removeItem(state, action: PayloadAction<string>) {
const existing = state.items.find((item) => item.id === action.payload);
if (existing) {
existing.quantity--;
state.totalQuantity--;
if (existing.quantity === 0) {
state.items = state.items.filter((item) => item.id !== action.payload);
}
}
},
clearCart(state) {
state.items = [];
state.totalQuantity = 0;
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
Configuring the Store and Providing It
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import cartReducer from './features/cartSlice';
const store = configureStore({
reducer: {
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
function App({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
export default App;
Using Typed Hooks for Clean Access
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../app/store';
import { addItem, removeItem } from '../features/cartSlice';
// Use these typed hooks throughout your app
const useAppDispatch = () => useDispatch<AppDispatch>();
const useAppSelector = <TSelected>(
selector: (state: RootState) => TSelected
) => useSelector(selector);
function Cart() {
const dispatch = useAppDispatch();
const items = useAppSelector((state) => state.cart.items);
const totalQuantity = useAppSelector((state) => state.cart.totalQuantity);
return (
<div>
<h2>Cart ({totalQuantity} items)</h2>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} - ${item.price} x {item.quantity}
<button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
</li>
))}
</ul>
<button
onClick={() =>
dispatch(addItem({ id: 'p1', name: 'Widget', price: 9.99 }))
}
>
Add Widget
</button>
</div>
);
}
export default Cart;
Pattern 5: Pinia for Vue + Vite Projects
If your Vite project uses Vue instead of React, Pinia is the official, recommended state management library. It is the spiritual successor to Vuex and integrates seamlessly with Vue's reactivity system and Vite's HMR.
Installing Pinia
npm install pinia
Registering Pinia in main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.mount('#app');
Defining a Store
import { defineStore } from 'pinia';
export const useProductStore = defineStore('products', {
state: () => ({
products: [] as Array<{ id: number; name: string; price: number }>,
loading: false,
}),
getters: {
totalProducts: (state) => state.products.length,
expensiveProducts: (state) => state.products.filter((p) => p.price > 50),
},
actions: {
async fetchProducts() {
this.loading = true;
try {
const res = await fetch('/api/products');
this.products = await res.json();
} finally {
this.loading = false;
}
},
addProduct(name: string, price: number) {
this.products.push({ id: Date.now(), name, price });
},
},
});
Using the Store in a Component
<script setup lang="ts">
import { useProductStore } from '../stores/products';
import { storeToRefs } from 'pinia';
const store = useProductStore();
const { products, loading, totalProducts } = storeToRefs(store);
</script>
<template>
<div>
<p>Total products: {{ totalProducts }}</p>
<button @click="store.fetchProducts" :disabled="loading">
{{ loading ? 'Loading...' : 'Fetch Products' }}
</button>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>
Pattern 6: Jotai for Atomic State
Jotai takes a fundamentally different approach from stores. Instead of a single global object, you define small, independent units of state called "atoms." Components subscribe only to the atoms they need, which leads to highly optimized re-renders. This model is especially powerful for apps with many independent, fine-grained pieces of state.
Installing Jotai
npm install jotai
Defining and Using Atoms
import { atom, useAtom } from 'jotai';
// A simple primitive atom
const countAtom = atom(0);
// A derived atom that depends on another atom
const doubleCountAtom = atom((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [double] = useAtom(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {double}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
export default Counter;
Jotai also supports async atoms for handling server data and atom families for dynamically creating atoms based on parameters.
Pattern 7: Managing Server State with TanStack Query
Not all state lives on the client. Data fetched from APIs โ including caching, refetching, synchronization, and optimistic updates โ has its own set of challenges that general-purpose state libraries were not designed to solve. TanStack Query (formerly React Query) is the leading solution for server state.
Installing TanStack Query
npm install @tanstack/react-query
Setting Up the Query Client
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import UsersList from './UsersList';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<UsersList />
</QueryClientProvider>
);
}
export default App;
Fetching and Mutating Data
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
type User = { id: number; name: string; email: string };
async function fetchUsers(): Promise<User[]> {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
}
async function addUser(newUser: Omit<User, 'id'>): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error('Failed to add user');
return res.json();
}
function UsersList() {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
const mutation = useMutation({
mutationFn: addUser,
onSuccess: () => {
// Invalidate the users query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<ul>
{data?.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
<button
onClick={() =>
mutation.mutate({ name: 'Jane Doe', email: 'jane@example.com' })
}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add User'}
</button>
</div>
);
}
export default UsersList;
By separating server state from client state, you keep your global stores lean and let TanStack Query handle caching, deduplication, background refetching, and stale-while-revalidate patterns automatically.
Best Practices for State Management in Vite
Regardless of which library you choose, following established best practices will keep your codebase healthy as it grows.
1. Keep State as Local as Possible
Not every piece of data needs to be global. Before lifting state up, ask whether more than one distant component truly needs it. Overusing global stores leads to unnecessary coupling and harder testing.
2. Separate Server State from Client State
Server data has different lifecycle requirements than UI state. Use a dedicated tool like TanStack Query, SWR, or RTK Query for API data, and reserve Zustand, Redux, or Jotai for genuinely client-side concerns like UI toggles, form drafts, and session preferences.
3. Normalize Complex State
When storing lists of entities, consider normalizing them into a dictionary keyed by ID. This makes lookups O(1) and simplifies updates.
// Instead of an array:
// const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
// Normalize into a record:
const normalizedUsers = {
byId: {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' },
},
allIds: [1, 2],
};
4. Use TypeScript for Type Safety
Vite projects typically use TypeScript, and all modern state libraries support it. Define explicit interfaces for your state shapes, actions, and selectors. This catches bugs at compile time and makes refactoring safer.
5. Leverage Vite's HMR Correctly
Ensure your state management setup preserves state during hot module replacement. Pinia handles this automatically. For Redux, the Redux DevTools extension integrates with HMR. For Zustand, avoid re-creating stores on every module reload by placing store definitions in dedicated files.
6. Write Selectors to Minimize Re-renders
Selectors let components subscribe to only the slice of state they care about. In Zustand, pass a selector function to the hook. In Redux, use useSelector with memoized selectors (via reselect or RTK's createSelector). In Jotai, derived atoms serve the same purpose.
7. Test Your Reducers and Stores Independently
Because reducers and store actions are pure functions, they are easy to test in isolation. Write unit tests that verify state transitions without rendering any components.
import cartReducer, { addItem, removeItem, clearCart } from './cartSlice';
describe('cartSlice', () => {
it('should add an item', () => {
const initialState = { items: [], totalQuantity: 0 };
const action = addItem({ id: 'p1', name: 'Widget', price: 9.99 });
const newState = cartReducer(initialState, action);
expect(newState.items).toHaveLength(1);
expect(newState.totalQuantity).toBe(1);
});
it('should remove an item', () => {
const state = {
items: [{ id: 'p1', name: 'Widget', price: 9.99, quantity: 2 }],
totalQuantity: 2,
};
const newState = cartReducer(state, removeItem('p1'));
expect(newState.items[0].quantity).toBe(1);
expect(newState.totalQuantity).toBe(1);
});
it('should clear the cart', () => {
const state = {
items: [{ id: 'p1', name: 'Widget', price: 9.99, quantity: 1 }],
totalQuantity: 1,
};
const newState = cartReducer(state, clearCart());
expect(newState.items).toHaveLength(0);
expect(newState.totalQuantity).toBe(0);
});
});
8. Persist Only What Matters
When using persistence middleware, be selective. Persisting entire stores can lead to stale data and versioning headaches. Persist only user preferences, authentication tokens, and draft data โ never transient UI state or cached server responses.
Choosing the Right Tool
With so many options, deciding which library to use can feel overwhelming. Here is a practical decision guide:
- Small to medium apps with simple shared state: React Context or Zustand.
- Large apps with complex state logic and team collaboration: Redux Toolkit.
- Vue projects: Pinia โ it is the official standard.
- Apps with many independent, fine-grained state units: Jotai.
- Any app with significant API data fetching: TanStack Query or RTK Query, often combined with one of the above for client state.
- Maximum simplicity with no dependencies: Built-in
useStateanduseReducerwith Context.
Remember that these tools are not mutually exclusive. A common, effective architecture in production Vite apps uses TanStack Query for server state, Zustand for lightweight global UI state, and local component state for ephemeral concerns.
Conclusion
State management is not a one-size-fits-all problem, and Vite's framework-agnostic nature gives you the freedom to choose the approach that best fits your project's scale and complexity. Start with the simplest solution that works โ local hooks and Context โ and introduce dedicated libraries like Zustand, Redux Toolkit, Pinia, or Jotai only when the pain of prop drilling or re-render performance justifies the added dependency. Always separate server state from client state, leverage TypeScript for safety, write selectors to optimize rendering, and test your state logic in isolation. By thoughtfully matching your state management strategy to your application's actual needs, you will build Vite projects that are fast to develop, easy to maintain, and ready to scale.