Key Takeaways
- Use Zustand for global client state, Jotai for local atomic and derived state, and TanStack Query for server-synced data — each tool excels in a different area of React 2026.
- This guide includes a local-first React app architecture with Vite, secure state hydration, offline patterns, mutation pipelines, derived state, and performance tuning for browser-based UIs.
- SovereignScore: 96/100 — the patterns emphasize local control, open-source packages, and minimized remote data dependencies through smart caching.
Direct Answer: Use Zustand when you need a fast, minimal global store for authenticated user state, feature flags, and client-side UI preferences. Use Jotai when you need fine-grained atomic state and derived selectors for complex local form state, dashboards, or isolated widgets. Use TanStack Query when you need server-state caching, background refresh, offline support, and mutation pipelines for REST or GraphQL data.
This article explains how to combine all three in a local-first React stack, with example code, performance guidance, and browser-based state validation.
Why React State Management Still Matters in 2026
React is no longer just a UI library; a modern React application is a full state machine that must coordinate:
- local UI state and component interactions
- persisted preferences and local caches
- server-synced lists, pagination, and mutation flows
- derived values and cross-component selectors
- offline-first behavior and resilient browser caching
In sovereign applications, you also want strong local control over where state is stored, how it is refreshed, and how much data is sent over the network. The right toolkit can make the difference between an app that is maintainable and one that devolves into global state spaghetti.
What This Guide Covers
- Installing the local React toolchain on Ubuntu 24.04
- Designing a hybrid state architecture with Zustand, Jotai, and TanStack Query
- Building a sample dashboard app that is local-first and network-aware
- Optimizing state updates, selectors, and derived state
- Using local caching, persistence, and offline fallbacks
- Handling optimistic updates and error recovery
- Benchmarking browser performance and avoiding React re-render pitfalls
- Governance patterns for state consistency and auditability
1. Setup: Local React Toolchain on Ubuntu 24.04
Install Node, pnpm, and Vite locally
sudo apt update
sudo apt install -y curl git build-essential libssl-dev
curl -fsSL https://get.pnpm.io/install.sh | sh -
source ~/.bashrc
pnpm env use --global stable
pnpm --version
Create the React app scaffold
mkdir -p ~/react-state-management-2026
cd ~/react-state-management-2026
pnpm create vite . --template react-ts
pnpm install
Add the state management dependencies
pnpm add zustand jotai @tanstack/react-query @tanstack/query-core axios
pnpm add -D vitest @testing-library/react @testing-library/jest-dom
Confirm the environment
pnpm exec node -v
pnpm exec tsc --noEmit
pnpm exec vite --version
This sets up a local-first React development environment without cloud-provisioned tooling.
2. Compare the Tools: What Each One is Best For
Zustand: Global and persistent client state
Zustand is ideal for:
- lightweight global state stores
- app-wide preferences and feature flags
- authentication tokens and user profiles
- non-server state that must be shared across screens
Strengths:
- tiny API surface
- no React context overhead when using selectors
- supports middleware for persistence and logging
Limitations:
- not optimized for server caching
- subscription semantics are explicit and need careful selector design
Jotai: Atomic, derived, and isolated state
Jotai is ideal for:
- local form state and wizard flows
- derived state with fine-grained dependency tracking
- isolated widgets, tabs, and canvas controls
- state that should not all live in one global store
Strengths:
- atomic model avoids monolithic stores
- derived atoms are recomputed only when needed
- excellent for composable state logic
Limitations:
- can be overkill for simple app-level flags
- mental model is different from typical Redux/Zustand apps
TanStack Query: Server state and background sync
TanStack Query is ideal for:
- fetching lists, paginated data, and entity queries
- caching server responses and invalidating stale data
- optimistic updates and mutation retries
- offline support and background refetching
Strengths:
- declarative data fetching hooks
- built-in caching, deduping, and stale-while-revalidate patterns
- excellent developer ergonomics for server state
Limitations:
- not designed for purely client-local UI state
- works best when you have a stable remote data contract
3. Application Architecture: Hybrid State for Sovereign React Apps
A mature React app often uses multiple state systems together. Here is the recommended architecture:
- Zustand for global local state and cross-cutting concerns
- Jotai for isolated, derived UI state inside widgets and forms
- TanStack Query for all server-synced data and remote cache
- Local storage or IndexedDB for persistent local preferences and offline cache
Example app structure
src/
app/
store.ts # Zustand global state
atoms.ts # Jotai atoms and derived state
queryClient.ts # TanStack Query client
features/
auth/
dashboard/
settings/
components/
Header.tsx
Sidebar.tsx
TaskTable.tsx
This separation keeps each state concern in the tool that handles it best.
4. Zustand: Global Store with Persistence and Devtools
4.1 Create the global store
src/app/store.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type AppState = {
theme: 'light' | 'dark';
user: { id: string; name: string } | null;
sidebarOpen: boolean;
setTheme: (theme: 'light' | 'dark') => void;
setUser: (user: AppState['user']) => void;
toggleSidebar: () => void;
};
export const useAppStore = create<AppState>()(
persist(
(set) => ({
theme: 'light',
user: null,
sidebarOpen: true,
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{
name: 'vucense-app-state',
partialize: (state) => ({ theme: state.theme, sidebarOpen: state.sidebarOpen }),
}
)
);
This store persists only UI preferences locally while keeping sensitive user data out of persisted storage if you choose.
4.2 Use Zustand selectors to minimize re-renders
In a component:
import { useAppStore } from '../app/store';
const SidebarToggle = () => {
const [sidebarOpen, toggleSidebar] = useAppStore((state) => [state.sidebarOpen, state.toggleSidebar]);
return <button onClick={toggleSidebar}>{sidebarOpen ? 'Close' : 'Open'} sidebar</button>;
};
This selector pattern ensures only components that care about sidebarOpen re-render.
4.3 Add middleware for logging and hydration
Zustand middleware can log state changes and integrate with local dev tools:
import { devtools } from 'zustand/middleware';
export const useAppStore = create<AppState>()(
devtools(
persist((set) => ({ ... }), { name: 'vucense-app-state' }),
{ name: 'VucenseAppStore' }
)
);
Use this for local debugging without external telemetry.
4.4 When not to use Zustand
Do not store server-synced lists or entity caches in Zustand. TanStack Query handles caching, stale data, and invalidation more reliably.
5. Jotai: Atomic State for Forms and Derived Values
Jotai shines when state is naturally local or when you need derived state with minimal re-render churn.
5.1 Create atoms for a complex form
src/app/atoms.ts:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const firstNameAtom = atom('');
export const lastNameAtom = atom('');
export const emailAtom = atom('');
export const fullNameAtom = atom((get) => {
const first = get(firstNameAtom);
const last = get(lastNameAtom);
return `${first} ${last}`.trim();
});
export const preferencesAtom = atomWithStorage('vucense-preferences', { language: 'en', timezone: 'UTC' });
This atomic model cleanly separates fields and derived values.
5.2 Use atoms in components
import { useAtom } from 'jotai';
import { firstNameAtom, lastNameAtom, fullNameAtom } from '../app/atoms';
const ProfileForm = () => {
const [firstName, setFirstName] = useAtom(firstNameAtom);
const [lastName, setLastName] = useAtom(lastNameAtom);
const [fullName] = useAtom(fullNameAtom);
return (
<div>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
<p>Full name: {fullName}</p>
</div>
);
};
Because atoms are independent, only the inputs that actually change will re-render.
5.3 Use derived atoms for computed state
Derived atoms help avoid recalculating values in every render:
export const totalTasksAtom = atom((get) => get(taskListAtom).length);
Use this for charts, summaries, and local dashboard logic.
5.4 Local storage and persistence with atomWithStorage
For sovereign apps, use local storage or IndexedDB to persist user preferences.
export const themeAtom = atomWithStorage<'light' | 'dark'>('vucense-theme', 'light');
This keeps the values on the client and does not require remote persistence.
5.5 When not to use Jotai
Avoid using Jotai for server state unless you have a very narrow, widget-specific requirement. TanStack Query is better suited for remote data that must be cached and refreshed.
6. TanStack Query: Server State, Caching, and Offline Synchronization
TanStack Query is the best fit for all server-synced data.
6.1 Create the query client
src/app/queryClient.ts:
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2,
cacheTime: 1000 * 60 * 30,
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
mutations: {
retry: false,
},
},
});
6.2 Wrap the app with QueryClientProvider
src/main.tsx:
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './app/queryClient';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);
6.3 Fetch server data with useQuery
src/features/dashboard/useTasks.ts:
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
export type Task = { id: string; title: string; completed: boolean; };
const fetchTasks = async (): Promise<Task[]> => {
const resp = await axios.get('/api/tasks');
return resp.data;
};
export const useTasks = () => {
return useQuery(['tasks'], fetchTasks, {
staleTime: 1000 * 60,
cacheTime: 1000 * 60 * 10,
retry: 2,
});
};
6.4 Use the query in a component
import { useTasks } from './useTasks';
const TaskList = () => {
const { data, isLoading, error } = useTasks();
if (isLoading) return <div>Loading tasks...</div>;
if (error) return <div>Error loading tasks.</div>;
return (
<ul>{data.map((task) => <li key={task.id}>{task.title}</li>)}</ul>
);
};
6.5 Mutations and optimistic updates
src/features/dashboard/useTaskMutations.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
const updateTask = async (task: Task) => {
const resp = await axios.put(`/api/tasks/${task.id}`, task);
return resp.data;
};
export const useUpdateTask = () => {
const queryClient = useQueryClient();
return useMutation(updateTask, {
onMutate: async (updatedTask) => {
await queryClient.cancelQueries(['tasks']);
const previous = queryClient.getQueryData<Task[]>(['tasks']);
queryClient.setQueryData<Task[]>(['tasks'], (old) =>
old?.map((task) => (task.id === updatedTask.id ? updatedTask : task)) ?? []
);
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['tasks'], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries(['tasks']);
},
});
};
This pattern gives local-first responsiveness while still reconciling with the server.
6.6 Offline support with cached queries
TanStack Query can continue to show cached data when offline. Use react-query persistence with localStorage or IndexedDB.
pnpm add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
Set up persistence in src/app/queryClient.ts:
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24,
});
This helps the browser remain useful when the network is unavailable.
7. Building a Local-First Dashboard Example
The best way to understand these tools together is a practical example.
7.1 App concept
Build a local dashboard that shows:
- current user profile from Zustand
- server-synced task list with TanStack Query
- local filters and derived counts with Jotai
- theme and layout state persisted in Zustand
7.2 Global store and query client wiring
src/App.tsx:
import { useAppStore } from './app/store';
import { useTasks } from './features/dashboard/useTasks';
import { useAtom } from 'jotai';
import { filterAtom, filteredTasksAtom } from './app/atoms';
const App = () => {
const theme = useAppStore((state) => state.theme);
const toggleSidebar = useAppStore((state) => state.toggleSidebar);
const { data: tasks } = useTasks();
const [filter, setFilter] = useAtom(filterAtom);
const [filteredTasks] = useAtom(filteredTasksAtom);
return (
<div className={theme === 'dark' ? 'theme-dark' : 'theme-light'}>
<header>
<button onClick={toggleSidebar}>Toggle Sidebar</button>
</header>
<main>
<div>
<label>
Filter:
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
</label>
</div>
<TaskTable tasks={filteredTasks} />
</main>
</div>
);
};
7.3 Derived filters with Jotai
src/app/atoms.ts addition:
export const filterAtom = atom('');
export const taskListAtom = atom<Task[]>([]);
export const filteredTasksAtom = atom((get) => {
const filter = get(filterAtom).toLowerCase();
const tasks = get(taskListAtom);
return tasks.filter((task) => task.title.toLowerCase().includes(filter));
});
Sync the query result into the atom only when tasks change:
const TaskContainer = () => {
const { data: tasks } = useTasks();
const [, setTaskList] = useAtom(taskListAtom);
useEffect(() => {
if (tasks) setTaskList(tasks);
}, [tasks, setTaskList]);
return <TaskTable />;
};
This hybrid pattern combines server state and local derived state cleanly.
7.4 Local-first failure UI
When the network is unavailable, use cached query data and show offline hints:
const { data, isFetching, isError, error } = useTasks();
if (isError) {
return <div>Unable to refresh tasks. Showing cached data if available.</div>;
}
Keep the app usable even if remote fetch fails.
8. Advanced State Patterns and Performance Tuning
8.1 Normalize server state
When your server returns nested entities, normalize them before storing or feeding into components. This reduces unnecessary re-renders and makes updates easier.
TanStack Query is good for caching raw server responses, but you can also use utility functions to normalize large lists.
8.2 Use shallow equality on Zustand selectors
For complex selector arrays:
const [theme, sidebarOpen] = useAppStore(
(state) => [state.theme, state.sidebarOpen],
shallow
);
This prevents re-render churn when unrelated state changes.
8.3 Prefer derived atoms over expensive selectors
In Jotai, derived atoms memoize automatically. Use them for expensive computations such as aggregated dashboard metrics.
8.4 Avoid storing ephemeral UI state in persisted storage
Persist only stable preferences. Avoid persisting fast-changing UI state such as hover targets, open dropdowns, or transient widgets.
8.5 Use react-query suspense carefully
If you enable suspense mode with TanStack Query, wrap components in an error boundary and a fallback UI.
9. Local Caching and Persistence Strategies
A sovereign React app should keep client state local where possible.
9.1 Persist UI preferences with Zustand middleware
Use persist and selectively store only the safe fields.
9.2 Persist query cache for offline reads
Use the TanStack Query persister with localStorage or IndexedDB.
9.3 Cache static reference data in Jotai
Atoms are great for reference lists that rarely change, like country codes or UI options.
9.4 Use local fallback data in absence of network
const tasksFallback = [{ id: 'offline-1', title: 'Offline task', completed: false }];
Use these local values only as a fallback when the network and cache are unavailable.
10. State Testing and Validation
Testing state logic is critical for maintainable React applications.
10.1 Test Zustand selectors and actions
Use unit tests for store actions:
import { act } from 'react-dom/test-utils';
import { useAppStore } from './store';
it('toggles sidebar', () => {
const { result } = renderHook(() => useAppStore((state) => state.sidebarOpen));
act(() => useAppStore.getState().toggleSidebar());
expect(result.current).toBe(false);
});
10.2 Test Jotai atoms
Use renderHook with Provider:
import { Provider, useAtom } from 'jotai';
const useName = () => useAtom(firstNameAtom);
10.3 Test TanStack Query hooks
Use QueryClientProvider with mock axios responses.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
10.4 Use component tests for state-driven UI
Test the Dashboard component with mocked server lists and atom values.
11. Migrating Existing React Apps to the Hybrid State Model
If you already have a legacy global store, migrate incrementally.
11.1 Extract feature slices to Jotai
Move widget-local state into atoms before refactoring the global store.
11.2 Replace server cache in Redux with TanStack Query
Start by using useQuery for one endpoint and keep the old store until the new flows are stable.
11.3 Move app settings into Zustand
Keep the existing store for the rest of the app while migrating theme, locale, and UI preferences into Zustand.
11.4 Understand your state boundary
A useful rule of thumb:
- if state is derived from the server, use TanStack Query
- if state belongs to a single component/widget, use Jotai
- if state is truly global and persistent, use Zustand
12. Toolchain Validation and Local Performance Benchmarks
Measure browser performance locally to verify your state design.
12.1 Use Vite build profiling
Run:
pnpm exec vite build --profile
Inspect bundle size and note the cost of state libraries.
12.2 Use React profiler in the browser
Open DevTools Profiler, record an interaction, and inspect which components render when state changes.
12.3 Benchmark state updates
Compare a global re-render with a selector-based update. If a component re-renders unexpectedly, refine the selector or split the state.
12.4 Use local network simulation
Simulate slow connections with the browser network throttling panel to verify offline and stale caching behavior.
13. Security and Sovereignty Considerations for React State
A local-first React app should treat state as part of the sovereign boundary.
13.1 Keep sensitive state out of persisted stores
Do not persist authentication tokens or PII in localStorage unless encrypted and explicitly authorized.
13.2 Use secure storage for session state
If you need to persist tokens, keep them in secure HTTP-only cookies or encrypted IndexedDB abstractions.
13.3 Avoid remote state dependencies for UI preferences
Store UI preferences client-side in Zustand; do not sync them to a remote service unless necessary.
13.4 Audit state transitions in critical flows
For sovereign operations, log state transitions in a local dev console or optional debug overlay without sending telemetry externally.
14. Progressive Enhancement and Accessibility
React state management should support all devices and connection qualities.
14.1 Graceful fallback for missing browser APIs
If localStorage or IndexedDB is unavailable, use in-memory fallbacks and warn the user.
14.2 Keep server state accessible when JS is disabled
For public pages, prefer server-side rendering or a static fallback with hydration. Use TanStack Query on top of SSR if needed.
14.3 Use state to improve accessibility
Manage focus state, keyboard navigation, and form validation through atoms and derived state.
15. Packaging and Deployment for Local-First React Apps
15.1 Build the production bundle
pnpm exec vite build
15.2 Serve locally with a secure origin
pnpm exec vite preview --host 127.0.0.1 --port 4173
15.3 Include source maps for local debugging
Enable source maps in production builds only for internal deployments.
16. Component-Level Patterns for State Efficiency
16.1 Use memoized callbacks
const toggle = useCallback(() => setTheme(theme === 'dark' ? 'light' : 'dark'), [theme]);
16.2 Avoid anonymous selector objects
Pass stable selectors to Zustand to prevent unnecessary updates.
16.3 Keep derived values in Jotai or selectors
Compute expensive values once in an atom rather than inside every render.
17. Example: Real-Time Settings Panel
Build a settings panel that persists changes and reflects server-side defaults.
- UI preferences in Zustand
- feature toggles as atoms in Jotai
- remote config loaded with TanStack Query
This pattern is common in local-first admin UIs.
18. Common Pitfalls and How to Avoid Them
18.1 Overusing global stores
If every bit of state lives in Zustand, you will lose the benefits of Jotai and TanStack Query.
18.2 Storing server state in local storage
Use TanStack Query caching and persistence, not manual snapshot storage.
18.3 Re-render storms
Use selectors and derived atoms to keep only the affected subtree updating.
18.4 Inconsistent mutation flows
Keep server mutations in TanStack Query and local UI state in Zustand or Jotai to avoid mixing concerns.
19. Monitoring and Debugging Local State
19.1 Add Zustand devtools locally
pnpm add -D zustand-middleware-devtools
19.2 Use Jotai debug hooks
Use useAtomDevTools or simple logging atoms in development builds.
19.3 Inspect TanStack Query cache
In the browser console, call queryClient.getQueryCache().findAll().
20. Final Recommendations for React State in 2026
- Use Zustand for lightweight global state and persisted UI preferences.
- Use Jotai for local atomic state and derived values inside widgets.
- Use TanStack Query for server-synced remote data, caching, and mutation pipelines.
- Keep each layer focused on a single responsibility.
- Measure re-renders and optimize selectors early.
- Persist only safe data locally and avoid over-serializing ephemeral state.
- Keep the architecture simple for sovereign, local-first applications.
21. Advanced Orchestration: Split State by Load and Locality
Large applications benefit from splitting state by where it is loaded and how often it changes.
21.1 Hydration-aware state loading
For apps that render on the server or pre-render pages, make sure global state hydration is deterministic. Use Zustand with hydration handlers when server-rendering values such as theme or selected workspace.
21.2 Split local cache from live server data
Keep transient UI state in Jotai atoms and server-backed entities in TanStack Query. This avoids entangling the stable query cache with fast-changing form state.
21.3 Load state lazily by route
Use code splitting to load feature stores only when needed. For example, lazy-load a large settings module and its Zustand slice only when the user opens the settings route.
const SettingsPage = lazy(() => import('./features/settings/SettingsPage'));
21.4 Prefer event-driven state updates
Use local event handlers and atom updates instead of polling when the UI can respond immediately. TanStack Query can still refresh in the background while Jotai handles the interactive form state.
21.5 Keep state ownership explicit
Document which library owns each piece of state. A state map helps teams avoid moving the same data between Zustand, Jotai, and TanStack Query multiple times.
This advanced orchestration pattern enables sovereign React apps to scale without losing clarity or local-first control.
People Also Ask
What makes React State Management 2026: Zustand vs Jotai vs TanStack Query relevant for sovereign infrastructure in 2026?
This guide maps each state tool to the right problem space in a local-first React app. It shows how to keep UI state, derived widget state, and server data distinct while preserving local control and minimizing unnecessary remote dependencies.
Can I use Zustand and Jotai together in the same app?
Yes. Use Zustand for broad global state and Jotai for isolated or derived component state. The combination is powerful when each tool is used for its strengths.
When should I use TanStack Query instead of custom data fetching hooks?
Use TanStack Query when you need caching, stale data management, background refetching, retries, and mutation workflows. Custom fetch hooks are okay for simple one-off data loads, but TanStack Query handles most server-state patterns robustly.
How do I keep React state manageable in a large local-first dashboard?
Separate concerns: global app state in Zustand, local widget state in Jotai, server data in TanStack Query, and persistence in storage middleware. Keep the structure consistent across features and use typed selectors to avoid hidden dependencies.
Further Reading
- Ubuntu 24.04 LTS Server Setup Checklist — base server configuration
- WebAssembly Rust 2026 — browser AI and Wasm patterns
- Rust for Systems Programming 2026 — secure local tooling with Rust and Wasm
- Python Automation Scripts 2026 — local scripting and scheduling for data pipelines
Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Last verified: May 2, 2026.