Skip to content

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:

components/UserList.jsx
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:

server/index.js
import express from 'express'
import cors from 'cors'
import pg from 'pg'
const { Pool } = pg
const app = express()
app.use(cors())
app.use(express.json())
const pool = new Pool({
connectionString: process.env.DATABASE_URL
})
// GET all users
app.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 user
app.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 user
app.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 user
app.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 user
app.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:

src/main.jsx
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:

api/users.js
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:

components/UserList.jsx
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:

components/UserProfile.jsx
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:

components/UserPosts.jsx
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

components/CreateUserForm.jsx
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:

components/EditUserForm.jsx
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

components/UserListItem.jsx
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

hooks/useUserMutations.js
const queryClient = useQueryClient()
// Invalidate all user-related queries
queryClient.invalidateQueries({ queryKey: ['users'] })
// Invalidate a specific user
queryClient.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:

pages/UsersPage.jsx
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:

src/main.jsx
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:

components/UserSearch.jsx
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:

  • searchTerm and isModalOpen are UI state (useState)
  • users comes 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 state
const { data: isModalOpen } = useQuery({
queryKey: ['modalOpen'],
queryFn: () => Promise.resolve(false)
})
// CORRECT: Use useState for UI state
const [isModalOpen, setIsModalOpen] = useState(false)

Mistake 2: Not Invalidation Cache

// WRONG: Mutation without invalidation - UI won't update
const mutation = useMutation({
mutationFn: createUser
})
// CORRECT: Invalidate related queries
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})

Mistake 3: Generic Query Keys

// WRONG: Too generic - hard to invalidate specifically
useQuery({ queryKey: ['data'], queryFn: fetchUsers })
// CORRECT: Specific, hierarchical keys
useQuery({ 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:

  1. Server state (data from your Express API) belongs in TanStack Query
  2. Client state (form inputs, modal visibility) stays in useState
  3. Use queries for reading data, mutations for writing data
  4. Always invalidate cache after mutations to keep data fresh
  5. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments