Client State vs Server State
Some data belongs to your app. Other data belongs to a server, and your app only holds a copy that can go out of date. Learn to tell them apart, and see - with real requests counted - why server data needs different tools.
What you will be able to do
- Explain the difference between client state and server state
- Show, with running code, how a copy of server data becomes stale
- List the extra problems server state brings: loading, errors, staleness, duplicate requests, refreshing after changes
- Count the duplicate requests when components fetch data by themselves
- Read a first RTK Query example and see it share one request and refresh automatically
- Split a screen’s data into client state and server state
- Explain why much "global state" is really server state - and what that means for Redux
The idea, in plain English
In Lesson 2 you sorted state by who uses it: local or global. This lesson sorts state by a different question: who owns it?
Client state is data your app owns. It exists only in the browser, and it changes only when your app changes it: the theme, whether a menu is open, the text in a form, which tab is selected. Nobody else can change it behind your back.
Server state is data that a server owns. The list of users, the courses, your orders, the number of unread notifications - these live in a database on a server, and other people and programs change them. Your app never owns this data. It only holds a copy, fetched at some moment in the past. That copy can be out of date a second later.
This difference matters because server data brings problems that client data does not have: waiting for the network, failed requests, stale copies, the same data fetched twice. In this lesson you will see each of these happen, using a small pretend server that counts every request. All code was run with Node 22, React 19.3, Redux Toolkit 2.13.0 and react-redux 9.3.0; the React examples were rendered and clicked in a test browser (jsdom).
Worked example: Why a user list is not really your state: another admin adds a user, and your copy is silently out of date.
1 - Your app takes a copy
The app asks the server for the users and stores what it gets. This copy is your app’s state now - but it is a copy, taken at one moment.
Example 1, a real run. Your app copies the users once; another admin then adds one on the server.
1 - Two components, one request
HeaderCount and UserList both ask for the users. The cache sees the same question twice and sends one request.
Example 3, a real run with RTK Query. Two components need the users; a third adds one.
An everyday picture: a photo of the train timetable
At the railway station you take a photo of the timetable on the board: "Hyderabad express, 6:40 pm". On the train you also write a note in your phone: "Sit near the window, wake me at Vijayawada".
Your note is yours. Nobody else can change it, and it is always correct, because you decide what it says. That is client state.
The photo is different. The railway owns the timetable, and the railway can change it at any time - a delay, a new platform. Your photo does not change. If you trust an old photo, you miss the train. To be sure, you must look at the board again, or the railway must send you a message. That is server state: a copy of someone else’s data, correct only at the moment you took it.
Your own noteClient state: your app owns it and is the only one who changes it.The timetable boardThe server: the real owner of the data.Your photo of the boardServer state in your app: a copy taken at one moment.The train is delayedSomeone else changes the server data. Your copy is now stale.Look at the board againRefetch: ask the server again.An SMS from the railwayThe server telling you about a change itself - for example with websockets.Sorting data by owner
Ask one question about each piece of data: if I reload the page on another computer, will it still be there? If yes, it lives on a server - it is server state. If no, your app created it - it is client state.
Look at the second column of the table. Server state is not rare. In most business apps - shops, dashboards, course sites like this one - most of the data on the screen comes from a server.
Is the menu open?Client. Only this browser tab knows or cares.Theme (day / night)Client. This site saves it in the browser, not on the server.Text typed in a formClient - until you press Save. Then it becomes server data.Which course is selectedClient. Only the ID of your choice.The list of coursesServer. The site owner adds courses on the server.The logged-in user’s profileServer. You can change it on another device.Your lesson progressServer. It follows you to your phone.Unread notificationsServer. New ones arrive without you doing anything.A pretend server for the examples
To see server state behave, we need a server. This small file pretends to be one. It owns a list of users, waits 50 ms to imitate the network, returns copies of its data, and counts every request - so we can see how many requests each approach makes.
// A pretend server: the real owner of the users. It counts every request.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const server = {
users: [
{ id: 1, name: "Ravi" },
{ id: 2, name: "Sita" },
],
requests: 0,
async getUsers() {
this.requests++;
await wait(50); // the network takes time
return this.users.map((user) => ({ ...user })); // you get a COPY
},
async addUser(name) {
this.requests++;
await wait(50);
const user = { id: this.users.length + 1, name };
this.users.push(user);
return user;
},
};Example 1: your copy goes stale
Our app fetches the users once and keeps them in a variable - its state. Then "another admin" adds Arjun directly on the server. Our app’s copy still has two users. It is not a bug in our code; the data was never ours. The only fix is to ask the owner again.
import { server } from "./server.mjs";
// Our app's "state": a copy of the users, taken once
let users = await server.getUsers();
console.log("app shows:", users.map((u) => u.name));
// Someone else - another admin, on another computer - adds a user
await server.addUser("Arjun");
console.log("server has:", server.users.map((u) => u.name));
console.log("app shows:", users.map((u) => u.name), "<- stale copy");
// The only way to be up to date: ask the owner again
users = await server.getUsers();
console.log("after refetch, app shows:", users.map((u) => u.name));
console.log("requests so far:", server.requests);app shows: [ 'Ravi', 'Sita' ]
server has: [ 'Ravi', 'Sita', 'Arjun' ]
app shows: [ 'Ravi', 'Sita' ] <- stale copy
after refetch, app shows: [ 'Ravi', 'Sita', 'Arjun' ]
requests so far: 3Why server state is harder
Client state has one job: hold a value and update the screen when it changes. Server state has that job plus a list of problems that come from the network and from not owning the data. Every app that shows server data must solve all of them, one way or another.
LoadingThe data is not there yet. What does the screen show for 50 ms - or 5 seconds?ErrorsThe request can fail: no internet, server down, not allowed.StalenessThe copy can be out of date at any moment (Example 1).Duplicate requestsTwo components need the same data. Do you fetch it twice?Refreshing after a changeAfter you add or edit something, every screen showing that data must update.MemoryHow long do you keep a copy you are not showing any more?Example 2: fetching by hand in each component
The most common first attempt: each component fetches what it needs in a useEffect, and keeps it in useState with its own loading and error flags. A small hook, useUsers, keeps the code short.
We rendered it and counted. Two components needed the users, so the server got 2 requests for the same data. Then we clicked "Add Arjun". The server now had 3 users - but both components still showed 2, because nothing told them to fetch again. Each one is holding its own old copy: the drift problem from Lesson 1, now with a server in the middle.
import { useEffect, useState } from "react";
import { server } from "./server.mjs";
// Each component fetches the users itself and keeps its own copy
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
server.getUsers()
.then((data) => setUsers(data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
return { users, loading, error };
}
function HeaderCount() {
const { users, loading } = useUsers();
return <header>{loading ? "..." : users.length + " users"}</header>;
}
function UserList() {
const { users, loading } = useUsers();
if (loading) return <p>Loading...</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
function AddUser() {
return <button onClick={() => server.addUser("Arjun")}>Add Arjun</button>;
}
export default function App() {
return (
<>
<HeaderCount />
<UserList />
<AddUser />
</>
);
}first paint header: "..." list: "Loading..." requests: 2 <- same data, twice
loaded header: "2 users" list: Ravi, Sita requests: 2
click Add Arjun header: "2 users" list: Ravi, Sita requests: 3 server has 3 users
^ both stale: nothing told them to fetch againExample 3: a cache built for server state (RTK Query)
Redux Toolkit includes a tool made for exactly these problems: RTK Query. You describe how to get the data (a query) and how to change it (a mutation), and it keeps one shared cached copy in the Redux store. Module 10 teaches it properly; for now, just watch what it does.
The same two components now call useGetUsersQuery(). We counted again. On the first paint the server got 1 request, not 2 - the cache noticed both components were asking the same question. isLoading came for free. When we clicked "Add Arjun", the mutation said the "Users" data was out of date (invalidatesTags), so the cache fetched the list again by itself. Both components showed 3 users, and we wrote no code to refresh them.
In a real app the baseQuery would be fetchBaseQuery({ baseUrl: "/api" }), calling a real HTTP API. We used fakeBaseQuery with queryFn so that the example can call our pretend server.
import { configureStore } from "@reduxjs/toolkit";
import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
import { Provider } from "react-redux";
import { server } from "./server.mjs";
// RTK Query: a cache for server state (Module 10 explains every line)
const usersApi = createApi({
reducerPath: "usersApi",
baseQuery: fakeBaseQuery(), // normally fetchBaseQuery({ baseUrl: "/api" })
tagTypes: ["Users"],
endpoints: (build) => ({
getUsers: build.query({
queryFn: async () => ({ data: await server.getUsers() }),
providesTags: ["Users"],
}),
addUser: build.mutation({
queryFn: async (name) => ({ data: await server.addUser(name) }),
invalidatesTags: ["Users"], // "the users list is now out of date"
}),
}),
});
const { useGetUsersQuery, useAddUserMutation } = usersApi;
export const store = configureStore({
reducer: { [usersApi.reducerPath]: usersApi.reducer },
middleware: (getDefault) => getDefault().concat(usersApi.middleware),
});
function HeaderCount() {
const { data: users, isLoading } = useGetUsersQuery();
return <header>{isLoading ? "..." : users.length + " users"}</header>;
}
function UserList() {
const { data: users, isLoading } = useGetUsersQuery();
if (isLoading) return <p>Loading...</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
function AddUser() {
const [addUser] = useAddUserMutation();
return <button onClick={() => addUser("Arjun")}>Add Arjun</button>;
}
export default function App() {
return (
<Provider store={store}>
<HeaderCount />
<UserList />
<AddUser />
</Provider>
);
}first paint header: "..." list: "Loading..." requests: 1 <- shared
loaded header: "2 users" list: Ravi, Sita requests: 1
click Add Arjun header: "3 users" list: Ravi, Sita, Arjun requests: 3 (add + automatic refetch)
by hand (Example 2) RTK Query (Example 3)
requests at load 2 1
after Add stale (2 users) up to date (3 users)
refresh code you write it invalidatesTagsWhy a user list is not really your state
Now the title of this lesson’s example makes sense. When your app shows a list of users, it is tempting to think "the users are in my state". But you did not create them, you cannot stop others from changing them, and your copy is only correct at the moment you fetched it. The user list is the server’s state. What your app holds is a cache - a temporary copy that must be refreshed.
Once you see it as a cache, the right questions become clear: when is the copy too old? Which screens share it? What must be refetched after a change? Those are cache questions, and tools like RTK Query (or TanStack Query, outside Redux) are built to answer them.
One screen, both kinds of state
Most screens mix the two. A user admin page shows a list of users from the server - server state. But which user is selected, what is typed in the filter box, and whether the "Add user" dialog is open are client state: only this browser tab knows them.
A useful pattern is to keep only IDs and choices in client state, and let the cache hold the data. Store selectedUserId = 2 (client), and get the details of user 2 from the server cache. If you store a whole copy of the selected user in client state, that copy can go stale too.
The users listServer state - from the cache (useGetUsersQuery).The selected user’s IDClient state - useState or the URL.The selected user’s detailsServer state - from the cache, using the ID.Text in the filter boxClient state - local useState.Is the "Add user" dialog open?Client state - local useState.The form inside the dialogClient state until Save; then a mutation sends it to the server.What this means for Redux
Before tools like RTK Query, many apps put server data into normal Redux slices: fetch in a thunk, then store users, loading and error by hand - for every kind of data. That is a lot of code, and every slice has the problems from Example 2 to solve again. You will still see this in older codebases, and Module 8 (Async Redux) teaches it so you can read them.
Today the usual split is: Redux slices (Lessons 1 and 2) for client state that many components share, and RTK Query for server state. Remember Lesson 2’s cart and logged-in user? In a real shop, the cart may be client state, but the user’s profile is server state. Once the server data moves into a cache, the amount of hand-written global state in most apps becomes small - another reason not to put everything in Redux.
Local client stateuseState in the component (Lesson 2).Shared client stateLift it up, React Context, or a Redux slice.Server stateA cache: RTK Query (Module 10), or TanStack Query outside Redux.Common questions
Is server state global state? It is often used in many places, so it feels global. But it needs caching, refetching and loading - things a plain global store does not do for you. Treat it as its own kind.
Is data in localStorage server state? No. localStorage is in the browser, and only your app changes it, so it is client state that survives a reload. This site saves your theme there.
Does RTK Query replace Redux? No. RTK Query is part of Redux Toolkit, and it keeps its cache inside the Redux store - you saw usersApi.reducer added to configureStore. It replaces the hand-written slices for server data, not Redux itself.
Can server state be up to date all the time? Not perfectly, because the network takes time. Tools reduce the gap - for example polling, which asks the server again every few seconds (Module 10), or the server pushing updates to the app.
This lesson at a glance
Client stateYour app owns it; only your app changes it.
const [menuOpen, setMenuOpen] = useState(false)
Server stateA server owns it; you hold a copy.
const users = await server.getUsers()
StaleThe copy is older than the server’s data.
RefetchAsk the server again for a fresh copy.
QueryRTK Query: how to get data.
build.query({ ... })MutationRTK Query: how to change data.
build.mutation({ ... })InvalidateMark cached data as out of date, so it is refetched.
invalidatesTags: ["Users"]
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Open your email app. List every piece of data on the screen and mark each one client or server.”
“In stale.mjs, add a second user with server.addUser before the refetch. How many requests are there at the end?”
“In ByHand.jsx, add a third component that calls useUsers(). How many requests does the server get now?”
“In WithRtkQuery.jsx, remove invalidatesTags from addUser. What do the components show after Add Arjun?”
What usually goes wrong
A fetched list is a copy, correct only when you fetched it. Plan when to refetch it.
✗ const users = await getUsers(); // and trust it forever✓ useGetUsersQuery() // a cache that knows when to refetchEach component sends its own request and keeps its own copy. Two components = two requests and two copies that can disagree.
✗ useEffect(() => { getUsers().then(setUsers) }, []) // in Header AND in List✓ const { data } = useGetUsersQuery() // one shared cacheAfter adding or editing, every screen showing that data must update. With RTK Query, a mutation invalidates the tag.
✗ addUser: build.mutation({ queryFn })✓ addUser: build.mutation({ queryFn, invalidatesTags: ["Users"] })Keep the selected ID in client state, and read the object from the cache, so it cannot go stale.
✗ const [selectedUser, setSelectedUser] = useState(user);✓ const [selectedId, setSelectedId] = useState(user.id);Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Sort into client or server state: (1) the current page number of a table, (2) the products in a shop, (3) whether dark mode is on, (4) the price of a product, (5) the text in a chat input, (6) the chat messages, (7) whether a toast message is showing, (8) the user’s saved addresses.
Show hintHide hint
Ask: if I open the site on another computer, is it still there?
Show solutionHide solution
(1) page number client (or in the URL)
(2) products server
(3) dark mode client (saved in the browser)
(4) product price server - and it can change while you look at it
(5) chat input text client until you press Send
(6) chat messages server - new ones arrive without you
(7) toast showing client
(8) saved addresses serverIn Example 2, after clicking "Add Arjun", why did neither HeaderCount nor UserList update?
Show hintHide hint
When does the useEffect in useUsers run?
Show solutionHide solution
useEffect(..., []) runs only once, when each component first appears.
Each component fetched its own copy then, and nothing ever asked again.
AddUser changed the server, but it does not know about the two copies,
and the copies do not know the server changed.A screen shows a list of orders and a detail panel for the selected order. Which of these should be client state: the orders, the selected order’s ID, the selected order object?
Show hintHide hint
Which one can never be stale?
Show solutionHide solution
Client state: only the selected order's ID.
The orders and the selected order's details are server state - read them
from the cache using the ID. A copied order object in useState can go stale.Build a tiny request cache
Write createCache(fetcher): a small cache for one piece of server data, using the pretend server. It must solve two of the problems from this lesson - duplicate requests and refreshing after a change.
- get() returns the cached copy if there is one, without a new request
- If two callers call get() at the same time, the server gets only one request
- invalidate() marks the copy as old, so the next get() fetches again
- Show the request count after each step
Show one solutionHide solution
import { server } from "./server.mjs";
function createCache(fetcher) {
let data = null; // the cached copy
let pending = null; // a request that is already on its way
return {
async get() {
if (data) return data; // 1. have a copy: use it
if (!pending) { // 2. nobody asked yet: ask once
pending = fetcher().then((result) => {
data = result;
pending = null;
return result;
});
}
return pending; // 3. share the same request
},
invalidate() {
data = null; // the copy is out of date
},
};
}
const users = createCache(() => server.getUsers());
const [a, b] = await Promise.all([users.get(), users.get()]); // two components at once
console.log("two callers got", a.length, "and", b.length, "users | requests:", server.requests);
await users.get();
console.log("third call (cached) | requests:", server.requests);
await server.addUser("Arjun");
users.invalidate();
console.log("after add + invalidate:", (await users.get()).map((u) => u.name), "| requests:", server.requests);
// two callers got 2 and 2 users | requests: 1
// third call (cached) | requests: 1
// after add + invalidate: [ 'Ravi', 'Sita', 'Arjun' ] | requests: 3Key points
- Client state is owned by your app; server state is owned by a server.
- Your app only holds a copy of server state, and that copy can go stale at any moment.
- Test: if you open the app on another computer, is the data still there? Then it is server state.
- Server state brings loading, errors, staleness, duplicate requests and refreshing after changes.
- Fetching in every component means duplicate requests and copies that are never refreshed.
- A cache like RTK Query shares one request, gives loading flags, and refetches after a mutation invalidates the data.
- Keep IDs and choices in client state; read the data itself from the cache.
- Use Redux slices for shared client state and RTK Query for server state.
Quick check before you move on
Interview questions
What is the difference between client state and server state?
Client state is owned by the frontend and changes only through it - UI state, form drafts, selections. Server state is owned by a backend and shared with other users; the frontend only holds a cached snapshot that can go stale and must be fetched, refreshed and invalidated.
What problems does server state introduce that client state does not?
Asynchronous loading, error handling, staleness, deduplicating identical requests, refetching after mutations, sharing one copy between components, and cache lifetime.
Why not store API responses in a normal Redux slice?
You can, but you must hand-write loading and error flags, deduplication, refetching and invalidation for every resource. A server-state cache like RTK Query provides these, so slices can focus on client state.
How does RTK Query keep the UI fresh after a mutation?
Queries provide tags and mutations invalidate them. When a mutation invalidates a tag, every active query that provides it is refetched automatically.
Where would you keep the currently selected item in a list loaded from an API?
Keep its ID in client state - component state or the URL - and derive the item from the server cache, so there is only one copy of the data.
Quiz
- 1.
Is "which tab is selected" client or server state? And "the list of products"?
- 2.
In Example 1, another admin added Arjun. Why did the app still show two users?
- 3.
Two components need the users. How many requests did fetching by hand send, and how many did RTK Query send?
- 4.
Should you store the selected user object in useState?
- 5.
Does RTK Query replace Redux?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned