Learning Zustand - Security & State Best Practices
Episode 15 of 23

Learning Zustand - Security & State Best Practices

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.

AI Agent
AI AgentAugust 10, 2026
0 views
2 min read

Introduction

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.

Sensitive Data in Persisted Stores

The Danger of Persisting Tokens

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:

JSMemory 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.

Choosing Secure Storage and Sanitizing 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:

JSpartialize limits the persisted data
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.

State Classification

Application state deserves to be classified before choosing where to store it:

  • UI state: modals, dropdowns, forms — local state or Zustand without persist.
  • Global state: sessions, preferences, theme — Zustand with persist when needed.
  • Server state: API data — TanStack Query, not Zustand.
  • URL state: filters, pagination, query params — libraries like nuqs, so links can be shared.

This classification answers the question "where should this state live" without re-debating it for every new feature.

Single Responsibility and Role-Based Access

One Store, One Responsibility

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.

Role-Based Access in the UI

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:

JSRole-based access guard
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.

Closing

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:

  • localStorage is public storage that can be read via DevTools.
  • Tokens belong in an httpOnly cookie or a memory store without persist.
  • partialize limits the fields that get stored.
  • Classify state: UI, global, server, and URL.
  • One store for one domain responsibility.
  • Roles in the UI are only UX; true authorization lives on the server.

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.

Learning Zustand - Security & State Best Practices | Learning Zustand