How to Separate Server State from UI State in Your PERN Stack with TanStack Query
Problem
I had been building a PERN stack application for months. Everything worked, but the state management was a mess.
My React components were full of this pattern:
function UserList() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [isModalOpen, setIsModalOpen] = useState(false)
useEffect(() => { setLoading(true) fetch('/api/users') .then(res => res.json()) .then(data => { setUsers(data) setLoading(false) }) .catch(err => { setError(err.message) setLoading(false) }) }, [])
// ... rest of the component}I had server data (users, loading, error) mixed with UI state (searchTerm, isModalOpen) in the same component. When I needed to add cache invalidation, background refetching, or optimistic updates, the complexity grew exponentially.
Then I read a Reddit comment that changed everything:
βpairing that with tanstack query on the frontend to manage server state separately from ui state is basically the 2025 standardβ - user orngcode
I switched to TanStack Query and the difference was immediate. Let me show you how.
Understanding Server State vs Client State
Before diving into code, let me clarify the two types of state:
Server State is data that lives on your backend:
- User profiles from PostgreSQL
- Posts, comments, products
- Any data you fetch from your Express API
- Asynchronous to access
- Can become βstaleβ without the client knowing
Client State is data that exists only in the browser:
- Form input values
- Modal open/close state
- Sidebar collapsed state
- Theme preferences
- Synchronous to access
When you mix these in useState, you create problems:
- Manual loading/error state tracking
- No automatic caching
- Cache invalidation scattered everywhere
- No background refetching when window regains focus
TanStack Query solves all of this for server state, leaving useState for true UI state.
Setting Up TanStack Query with Express
Express Backend
First, let me show you the Express backend. It is straightforward REST endpoints:
import express from 'express'import cors from 'cors'import pg from 'pg'
const { Pool } = pgconst app = express()
app.use(cors())app.use(express.json())
const pool = new Pool({ connectionString: process.env.DATABASE_URL})
// GET all usersapp.get('/api/users', async (req, res) => { try { const result = await pool.query('SELECT id, name, email FROM users ORDER BY created_at DESC') res.json(result.rows) } catch (error) { console.error('Failed to fetch users:', error) res.status(500).json({ error: 'Failed to fetch users' }) }})
// GET single userapp.get('/api/users/:id', async (req, res) => { try { const result = await pool.query('SELECT id, name, email FROM users WHERE id = $1', [req.params.id]) if (result.rows.length === 0) { return res.status(404).json({ error: 'User not found' }) } res.json(result.rows[0]) } catch (error) { res.status(500).json({ error: 'Failed to fetch user' }) }})
// POST create userapp.post('/api/users', async (req, res) => { const { name, email } = req.body try { const result = await pool.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email', [name, email] ) res.status(201).json(result.rows[0]) } catch (error) { res.status(400).json({ error: 'Failed to create user' }) }})
// PUT update userapp.put('/api/users/:id', async (req, res) => { const { name, email } = req.body try { const result = await pool.query( 'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email', [name, email, req.params.id] ) if (result.rows.length === 0) { return res.status(404).json({ error: 'User not found' }) } res.json(result.rows[0]) } catch (error) { res.status(400).json({ error: 'Failed to update user' }) }})
// DELETE userapp.delete('/api/users/:id', async (req, res) => { try { await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]) res.status(204).send() } catch (error) { res.status(500).json({ error: 'Failed to delete user' }) }})
app.listen(3001, () => console.log('Server running on port 3001'))React Setup
Now wrap your React app with QueryClientProvider:
import React from 'react'import ReactDOM from 'react-dom/client'import { QueryClient, QueryClientProvider } from '@tanstack/react-query'import App from './App'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime) retry: 1, refetchOnWindowFocus: true } }})
ReactDOM.createRoot(document.getElementById('root')).render( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider>)API Functions
Create a clean API layer:
const API_URL = 'http://localhost:3001/api'
export async function fetchUsers() { const response = await fetch(`${API_URL}/users`) if (!response.ok) { throw new Error('Failed to fetch users') } return response.json()}
export async function fetchUser(id) { const response = await fetch(`${API_URL}/users/${id}`) if (!response.ok) { throw new Error('Failed to fetch user') } return response.json()}
export async function createUser(data) { const response = await fetch(`${API_URL}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) if (!response.ok) { throw new Error('Failed to create user') } return response.json()}
export async function updateUser(id, data) { const response = await fetch(`${API_URL}/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) if (!response.ok) { throw new Error('Failed to update user') } return response.json()}
export async function deleteUser(id) { const response = await fetch(`${API_URL}/users/${id}`, { method: 'DELETE' }) if (!response.ok) { throw new Error('Failed to delete user') }}Queries: Reading Data
Now the fun part. Here is how I replaced my messy useEffect with clean TanStack Query:
import { useQuery } from '@tanstack/react-query'import { fetchUsers } from '../api/users'
function UserList() { const { data: users, isLoading, error, refetch } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, staleTime: 1000 * 60 * 5 // Data stays fresh for 5 minutes })
if (isLoading) return <div>Loading users...</div> if (error) return <div>Error: {error.message}</div>
return ( <div> <button onClick={() => refetch()}>Refresh</button> <ul> {users.map(user => ( <li key={user.id}>{user.name} - {user.email}</li> ))} </ul> </div> )}No more useEffect. No more manual loading state. Automatic caching. Background refetching when the window regains focus. This is what the β2025 standardβ looks like.
Query with Parameters
For fetching a single user:
import { useQuery } from '@tanstack/react-query'import { fetchUser } from '../api/users'
function UserProfile({ userId }) { const { data: user, isLoading, isError } = useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId), enabled: !!userId // Only run if userId exists })
if (isLoading) return <div>Loading...</div> if (isError) return <div>Error loading user</div>
return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> )}The enabled option is useful when you only want to run the query when certain conditions are met.
Dependent Queries
Sometimes you need to wait for one query before running another:
function UserPosts({ userId }) { const { data: user } = useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) })
const { data: posts } = useQuery({ queryKey: ['users', userId, 'posts'], queryFn: async () => { const response = await fetch(`/api/users/${userId}/posts`) return response.json() }, enabled: !!user?.hasPosts })
return ( <div> <h3>{user?.name}'s Posts</h3> {posts?.map(post => <div key={post.id}>{post.title}</div>)} </div> )}Mutations: Writing Data
Reading data is straightforward. But the real power of TanStack Query shows in mutations with cache invalidation.
Create Mutation
import { useState } from 'react'import { useMutation, useQueryClient } from '@tanstack/react-query'import { createUser } from '../api/users'
function CreateUserForm() { const queryClient = useQueryClient() const [formData, setFormData] = useState({ name: '', email: '' })
const mutation = useMutation({ mutationFn: createUser, onSuccess: (newUser) => { // Invalidate the users query to trigger a refetch queryClient.invalidateQueries({ queryKey: ['users'] }) } })
const handleSubmit = (e) => { e.preventDefault() mutation.mutate(formData) }
return ( <form onSubmit={handleSubmit}> <input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Name" /> <input value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="Email" /> <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create User'} </button> {mutation.isError && <div>Error: {mutation.error.message}</div>} </form> )}Update Mutation with Optimistic Update
Optimistic updates make your UI feel instant. You update the cache immediately, then roll back if the mutation fails:
import { useMutation, useQueryClient } from '@tanstack/react-query'import { updateUser } from '../api/users'
function EditUserForm({ userId, initialData }) { const queryClient = useQueryClient()
const mutation = useMutation({ mutationFn: (data) => updateUser(userId, data), onMutate: async (newData) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['users', userId] })
// Snapshot previous value const previousUser = queryClient.getQueryData(['users', userId])
// Optimistically update to the new value queryClient.setQueryData(['users', userId], (old) => ({ ...old, ...newData }))
return { previousUser } }, onError: (err, newData, context) => { // Rollback on error queryClient.setQueryData(['users', userId], context.previousUser) }, onSettled: () => { // Refetch after error or success queryClient.invalidateQueries({ queryKey: ['users', userId] }) } })
// ... form implementation}Delete Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query'import { deleteUser } from '../api/users'
function UserListItem({ user }) { const queryClient = useQueryClient()
const deleteMutation = useMutation({ mutationFn: () => deleteUser(user.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) } })
return ( <li> {user.name} ({user.email}) <button onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} > {deleteMutation.isPending ? 'Deleting...' : 'Delete'} </button> </li> )}Cache Invalidation Patterns
Cache invalidation is where many developers make mistakes. Here are the patterns I use:
Manual Invalidation
const queryClient = useQueryClient()
// Invalidate all user-related queriesqueryClient.invalidateQueries({ queryKey: ['users'] })
// Invalidate a specific userqueryClient.invalidateQueries({ queryKey: ['users', userId] })
// Invalidate all queries (nuclear option - use sparingly)queryClient.invalidateQueries()Invalidation on Component Mount
When navigating to a page, you often want fresh data:
import { useEffect } from 'react'import { useQuery, useQueryClient } from '@tanstack/react-query'
function UsersPage() { const queryClient = useQueryClient()
useEffect(() => { // Invalidate on mount to get fresh data queryClient.invalidateQueries({ queryKey: ['users'] }) }, [])
const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
return <UserList users={users} />}Global Mutation Invalidation
For apps with many related queries, set up global invalidation:
const queryClient = new QueryClient({ defaultOptions: { mutations: { onSuccess: () => { // Optionally invalidate all queries after any successful mutation // queryClient.invalidateQueries() } } }})I use this sparingly because it can cause over-fetching. Better to be specific about what to invalidate.
Combining Server State with Client State
Here is a complete example showing both types of state working together:
import { useState } from 'react'import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import { fetchUsers, createUser } from '../api/users'
function UserSearch() { // Client state for UI const [searchTerm, setSearchTerm] = useState('') const [isModalOpen, setIsModalOpen] = useState(false)
// Server state with TanStack Query const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const queryClient = useQueryClient() const createMutation = useMutation({ mutationFn: createUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) setIsModalOpen(false) } })
// Filter users in memory (client-side operation) const filteredUsers = users?.filter(user => user.name.toLowerCase().includes(searchTerm.toLowerCase()) ) ?? []
return ( <div> {/* UI state: search input */} <input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} placeholder="Search users..." />
{/* UI state: modal toggle */} <button onClick={() => setIsModalOpen(true)}>Add User</button>
{/* Server state: user list */} {isLoading ? ( <div>Loading...</div> ) : ( <ul> {filteredUsers.map(user => ( <li key={user.id}>{user.name} - {user.email}</li> ))} </ul> )}
{/* UI state: modal */} {isModalOpen && ( <Modal onClose={() => setIsModalOpen(false)}> <CreateUserForm mutation={createMutation} onSuccess={() => setIsModalOpen(false)} /> </Modal> )} </div> )}Notice the clear separation:
searchTermandisModalOpenare UI state (useState)userscomes from the server (TanStack Query)- Filtering happens client-side on cached data
Common Mistakes to Avoid
Mistake 1: Using TanStack Query for Client State
// WRONG: TanStack Query is for server stateconst { data: isModalOpen } = useQuery({ queryKey: ['modalOpen'], queryFn: () => Promise.resolve(false)})
// CORRECT: Use useState for UI stateconst [isModalOpen, setIsModalOpen] = useState(false)Mistake 2: Not Invalidation Cache
// WRONG: Mutation without invalidation - UI won't updateconst mutation = useMutation({ mutationFn: createUser})
// CORRECT: Invalidate related queriesconst mutation = useMutation({ mutationFn: createUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) }})Mistake 3: Generic Query Keys
// WRONG: Too generic - hard to invalidate specificallyuseQuery({ queryKey: ['data'], queryFn: fetchUsers })
// CORRECT: Specific, hierarchical keysuseQuery({ queryKey: ['users'], queryFn: fetchUsers })useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) })useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId) })Summary
In this post, I showed you how to separate server state from UI state in your PERN stack application using TanStack Query.
Key takeaways:
- Server state (data from your Express API) belongs in TanStack Query
- Client state (form inputs, modal visibility) stays in useState
- Use queries for reading data, mutations for writing data
- Always invalidate cache after mutations to keep data fresh
- Use specific query keys like
['users']and['users', userId]
This separation eliminates sync issues, reduces boilerplate, and provides professional-grade features like caching, background refetching, and optimistic updates out of the box. It is the 2025 standard for React state management in the PERN stack.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- π¨βπ» Reddit Discussion: Learn PERN Stack
- π¨βπ» TanStack Query Documentation
- π¨βπ» React Query Migration Guide
Oh, and if you found these resources useful, donβt forget to support me by starring the repo on GitHub!
Comments