This episode covers security and state management best practices: why tokens should not live in the store, how to sanitize API data, role-based access protection, a truly-global minimal state principle, and keeping server state in RTK Query.

State management isn't just about speed and structure — it also concerns security. Whatever you store in the Redux store, with Redux DevTools installed, can be read by anyone who opens the browser console. Episode 15 covers the boundary of which data is safe to put in the store and which should live somewhere else.
We'll discuss handling tokens and sensitive data, sanitizing data that comes from the API, role-based access protection, then the minimal-state principle: only data that is truly global and needed by many components deserves to live in Redux, while server state is handled by RTK Query.
Redux DevTools renders the entire state tree. Storing a JWT in the store means the token can be viewed, copied, and even exported by anyone with access to the DevTools tab. The danger grows when the app is hydrated from the server: the token gets sent along in the HTML and is visible in view-source.
{
"auth": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9...",
"refreshToken": "r_9f2c1a"
}
}The main rule: never store refreshToken in the store — once it leaks, an attacker can renew the session forever. Even the access token should only remain in memory when a reducer genuinely needs to read it.
An access token used only as a request header is best stored outside Redux:
let accessToken: string | null = null
export function setAccessToken(token: string | null) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}The in-memory module above is never visible in Redux DevTools and resets when the page closes. For persistence across reloads, use an httpOnly cookie managed by the server — not localStorage, which XSS scripts can read.
API responses often carry internal fields the UI doesn't need: internalNotes, debugFlag, adminPanelUrl. Storing all of them opens up the risk of information leaking to the console. Reshape the data before it enters state:
const toPublicUser = (raw) => ({
id: raw.id,
name: raw.name,
email: raw.email,
role: raw.role,
})
export const fetchUsers = createAsyncThunk("users/fetchUsers", async () => {
const res = await fetch("/api/users")
const raw = await res.json()
return raw.map(toPublicUser)
})toPublicUser only picks the fields the UI actually displays. Sanitizing like this also keeps the state small and prevents other data consumers from reading fields they shouldn't see.
Enforce the data shape from the start with TypeScript and, when needed, runtime validation:
interface SafeUser {
id: number
name: string
email: string
role: "admin" | "editor" | "viewer"
}
const isSafeUser = (u): u is SafeUser =>
typeof u.id === "number" &&
typeof u.name === "string" &&
typeof u.email === "string" &&
["admin", "editor", "viewer"].includes(u.role)isSafeUser is a type guard that validates the data shape at runtime. You can combine it with zod or io-ts for applications that need stricter guarantees.
The primary protection always lives on the server, but role state helps the UI show or hide actions according to access rights. Store the role from the server's authorization result, then create a selector:
import { createSelector } from "@reduxjs/toolkit"
const selectAuth = (state) => state.auth
export const selectCurrentRole = createSelector(
[selectAuth],
(auth) => auth.user?.role ?? null,
)
export const selectCanEdit = createSelector(
[selectCurrentRole],
(role) => role === "admin" || role === "editor",
)Components use selectCanEdit to show the edit button. It's important to remember: this is only UI convenience, not authorization. Requests to the server must still be re-validated with server-side access rights.
Never trust a role sent by the client. Take the role from a server-verified token claim or from the /api/me endpoint:
export const fetchMe = createAsyncThunk("auth/fetchMe", async () => {
const res = await fetch("/api/me", {
headers: { Authorization: `Bearer ${getAccessToken()}` },
})
return res.json()
})fetchMe determines the role from the server response. The store only holds the authorization result — it never becomes the source of truth for security.
Before adding a slice, ask: is this data needed by many components in different places? If it's only used by one subtree, keep it in the component's local state. The rule of thumb:
1. Apakah data ini dibaca oleh banyak komponen berbeda?
2. Apakah data ini berubah di banyak tempat dan harus sinkron?
3. Apakah data ini perlu dilacak untuk debugging lintas waktu?Answered yes to all three? Put it in Redux. If not, leave it in local state or a small context. A store that's too fat is slower to debug and triggers re-renders more often.
Server state — data that comes from the API and is cached — isn't the job of a manual slice. Put it in RTK Query as in episodes 8 and 9 so caching, invalidation, and re-fetching run automatically:
export const notificationsApi = createApi({
reducerPath: "notificationsApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getNotifications: builder.query<Notification[], void>({
query: () => "notifications",
}),
}),
})As a result, manual slices only focus on UI and client-only state. Data that can change from many sources is handled by a system that understands its lifecycle.
Warning
Balance the number of slices per feature. One giant slice holding many domains makes debugging hard; ten micro-slices for a single screen just add overhead. One slice per clear domain is a healthy middle ground.
State security starts with the decision of what may enter the store. Tokens and refresh tokens are kept away from Redux DevTools, API data is sanitized before being stored, and roles only control the UI — not authorization. Best practices complete the picture: Redux state only for truly global data, server state handed to RTK Query, and a balanced number of slices per feature.
Key takeaways:
refreshToken in the store; the access token only when genuinely necessary.In the next episode, episode 16 covers custom middleware & enhancers — you'll understand middleware structure, write custom middleware for logging and telemetry, and extend the store with enhancers and RTK's default middleware composition.