This episode covers state security in Zustand: why tokens must not go into persisted stores, how to choose secure storage, and how to structure a state classification. You also learn role-based access control in the UI with server authorization as the primary layer of protection.

State management isn't just about performance — it's also about security. Episode 15 covers how to treat sensitive data inside Zustand: why tokens must not go into persisted stores, how to choose secure storage, and how to structure a state classification so the application is easy to maintain and audit. We also cover role-based access control in the UI.
After this episode, you'll know how to put state in its right place and keep sensitive data from leaking into storage that anyone can read.
The persist middleware writes state to localStorage or other storage as plain text. Anyone opening DevTools can read those values. Storing an access token in a persisted store is like taping the token to the browser's wall.
Tokens should be stored in a server-managed httpOnly cookie, or in a memory store without persist. If the token must live in Zustand for requests, create a separate store without persist:
import { create } from 'zustand'
interface TokenState {
token: string | null
setToken: (token: string) => void
clearToken: () => void
}
export const useTokenStore = create<TokenState>()((set) => ({
token: null,
setToken: (token) => set({ token }),
clearToken: () => set({ token: null }),
}))The useTokenStore without the persist middleware only keeps the token in memory. When the tab is closed, the token disappears — exactly what you want for sensitive session data.
When persist is still necessary, limit the stored data with partialize so only safe fields are included. Never store secrets or full PII in storage:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useUserStore = create<UserState>()(
persist(
(set) => ({
id: null,
email: '',
role: 'guest',
token: null,
setSession: (data) => set(data),
}),
{
name: 'user-prefs',
partialize: (state) => ({
email: state.email,
role: state.role,
}),
},
),
)partialize: (state) => ({ email, role }) makes sure only email and role are stored, while the token stays in memory. This is minimal sanitization: explicitly separate sensitive and non-sensitive fields.
Application state deserves to be classified before choosing where to store it:
This classification answers the question "where should this state live" without re-debating it for every new feature.
A store that holds everything becomes hard to test and predict. Create one store per domain: useAuthStore, useCartStore, useUiStore. When a store starts handling two unrelated concerns, split it into slices or a new store.
Roles and permissions determine what a user is allowed to see. Store the role in the store, then keep the UI rendering only the allowed elements:
const role = useAuthStore((s) => s.user?.role)
function AdminButton() {
if (role !== 'admin') {
return null
}
return <button onClick={() => handleDelete()}>Hapus</button>
}if (role !== 'admin') is a UI layer, not the primary protection. Validation must still happen on the server — UI access control only hides, it doesn't secure. Always use two layers: frontend for UX, backend for authorization.
Episode 15 covers state security: not storing tokens in persisted stores, limiting data with partialize, choosing secure storage, classifying state, and applying role-based access in the UI with server authorization as the foundation.
Key takeaways:
In the next episode we will discuss custom middleware and the slice pattern — writing your own middleware as a wrapper, using logging and tracking, splitting large stores into slices, and typing slices so they can access each other.