Files
dotcom-arcade/docs/04-frontend/state-management/zustand-stores.md
T
branderson 4130adfe58 docs: document Dotcom Arcade, streaming, proxy sign-in and saves
A new README for the combined project with setup from the Flashpoint
Ultimate torrent. New feature docs for streaming, proxy sign-in and
game saves; deployment, upgrade and streaming READMEs updated for the
single compose file; the project renamed throughout.
2026-10-13 19:31:06 -07:00

17 KiB

Zustand Stores

This document describes the Zustand stores used for client-side state management in the Dotcom Arcade frontend.

Overview

The application uses three main Zustand stores for different aspects of UI state:

  1. useAuthStore - Authentication state, JWT tokens, user permissions
  2. useThemeStore - Theme mode (light/dark/system), primary color
  3. useUIStore - Sidebar visibility, view modes, card sizes

All stores use the persist middleware to save state to localStorage (or sessionStorage for guest mode).

useAuthStore

Manages authentication state, user information, and JWT tokens.

Location

D:\Repositories\Personal\flashpoint-web\frontend\src\store\auth.ts

State

interface AuthState {
  user: User | null;
  accessToken: string | null; // Memory-only, not persisted
  isAuthenticated: boolean;
  isGuest: boolean;
  isMaintenanceMode: boolean;

  // Actions
  setAuth: (user: User, tokens: AuthTokens) => void;
  setGuestMode: () => void;
  clearAuth: () => void;
  updateAccessToken: (token: string) => void;
  updateUser: (user: User) => void;

  // Permission helpers
  hasPermission: (permission: string) => boolean;
  hasRole: (role: string) => boolean;
  hasAnyPermission: (permissions: string[]) => boolean;
  hasAllPermissions: (permissions: string[]) => boolean;
}

Important:

  • accessToken is stored in memory only (not persisted to localStorage)
  • refreshToken is stored in an HTTP-only cookie (handled automatically by the browser)
  • Only user, isAuthenticated, isGuest, and isMaintenanceMode are persisted to localStorage (via partialize)

Usage

import { useAuthStore } from '@/store/auth';

function MyComponent() {
  // Select specific state
  const user = useAuthStore((state) => state.user);
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);

  // Use multiple state values
  const { isGuest, hasPermission } = useAuthStore();

  // Check permissions
  if (hasPermission('games.play')) {
    // Render play button
  }
}

Actions

setAuth

Sets authenticated user and access token after successful login:

const { setAuth } = useAuthStore();

const handleLogin = async (credentials) => {
  const response = await authApi.login(credentials);
  setAuth(response.user, {
    accessToken: response.accessToken,
    expiresIn: response.expiresIn,
  });
  // Refresh token is automatically stored in HTTP-only cookie by backend
};

Note: Only the access token is passed to setAuth(). The backend automatically sets the refresh token in an HTTP-only cookie during login, which the browser will include in all subsequent requests.

setGuestMode

Enables guest mode with limited permissions:

const { setGuestMode } = useAuthStore();

setGuestMode();
// Creates a guest user with permissions:
// ['games.read', 'playlists.read', 'games.play']

clearAuth

Logs out the user and clears all auth state:

const { clearAuth } = useAuthStore();

const handleLogout = () => {
  clearAuth();
  navigate('/login');
};

updateAccessToken

Updates the access token when refreshed via the axios interceptor:

const { updateAccessToken } = useAuthStore();

// Called by axios interceptor on 401 after successful token refresh
updateAccessToken(newAccessToken);

This is called by the response interceptor when a token refresh succeeds. The refresh token is automatically sent via HTTP-only cookie, and the new access token is stored in memory only.

Permission Helpers

hasPermission

Checks if user has a specific permission:

const { hasPermission } = useAuthStore();

if (hasPermission('users.manage')) {
  // Show admin panel
}

hasRole

Checks if user has a specific role:

const { hasRole } = useAuthStore();

if (hasRole('admin')) {
  // Show admin features
}

hasAnyPermission

Checks if user has any of the specified permissions:

const { hasAnyPermission } = useAuthStore();

if (hasAnyPermission(['users.read', 'roles.read', 'activities.read'])) {
  // Show management section
}

hasAllPermissions

Checks if user has all specified permissions:

const { hasAllPermissions } = useAuthStore();

if (hasAllPermissions(['users.read', 'users.write', 'users.delete'])) {
  // Show full user management
}

Persistence Strategy

The auth store uses selective persistence with the partialize middleware:

partialize: (state) => ({
  user: state.user,
  isAuthenticated: state.isAuthenticated,
  isGuest: state.isGuest,
  isMaintenanceMode: state.isMaintenanceMode,
  // accessToken is intentionally NOT persisted (memory-only)
});

storage: createJSONStorage(() => ({
  getItem: (name) => {
    // Check sessionStorage first for guest sessions
    const sessionValue = sessionStorage.getItem(name);
    if (sessionValue) {
      const parsed = JSON.parse(sessionValue);
      if (parsed?.state?.isGuest) {
        return sessionValue;
      }
    }
    // Fall back to localStorage for authenticated sessions
    return localStorage.getItem(name);
  },
  setItem: (name, value) => {
    const parsed = JSON.parse(value);
    if (parsed?.state?.isGuest) {
      // Guest sessions go to sessionStorage only
      sessionStorage.setItem(name, value);
    } else {
      // Authenticated sessions go to localStorage
      localStorage.setItem(name, value);
      sessionStorage.removeItem(name);
    }
  },
  removeItem: (name) => {
    localStorage.removeItem(name);
    sessionStorage.removeItem(name);
  },
}));

Token Storage Strategy:

  • Access Token: Memory-only in Zustand store, never persisted
  • Refresh Token: HTTP-only cookie, sent automatically by browser
  • User Info: Persisted to localStorage (restored on page reload)
  • Guest Sessions: Stored in sessionStorage only, cleared on browser close

Benefits:

  • XSS attacks cannot steal access token (memory-only)
  • CSRF protection via HTTP-only cookies (auto-sent by browser)
  • Guest sessions cleared on browser close (sessionStorage)
  • Authenticated sessions persist across browser restarts (localStorage)
  • On page reload: if isAuthenticated but accessToken is null, the app calls /auth/refresh to recover the session using the persisted refresh token cookie

Session Recovery on Page Reload

The app implements session recovery using the persisted refresh token cookie:

// In AuthContext or root App component
useEffect(() => {
  const { isAuthenticated, accessToken } = useAuthStore.getState();

  // If authenticated but no access token (page reload), refresh from cookie
  if (isAuthenticated && !accessToken) {
    authApi
      .refreshToken()
      .then((tokens) => {
        useAuthStore.getState().updateAccessToken(tokens.accessToken);
      })
      .catch(() => {
        // Refresh failed, clear auth
        useAuthStore.getState().clearAuth();
      });
  }
}, []);

After successful login, the auth store automatically loads theme settings from the server:

setAuth: (user: User, tokens: AuthTokens) => {
  set({
    user,
    accessToken: tokens.accessToken,
    isAuthenticated: true,
    isGuest: false,
  });

  // Load theme settings from server
  const themeStore = useThemeStore.getState();
  themeStore.loadThemeFromServer().catch((error) => {
    console.debug('Failed to load theme settings after login:', error);
  });
};

How it works:

  1. On login: backend sets refresh token in HTTP-only cookie, user info and isAuthenticated are persisted to localStorage
  2. On page reload: Zustand rehydrates from localStorage, checks if isAuthenticated && !accessToken
  3. If true: calls /auth/refresh (cookie sent automatically), recovers access token
  4. If false or refresh fails: clears auth state and shows login screen

useThemeStore

Manages theme mode (light/dark/system) and primary color selection.

Location

D:\Repositories\Personal\flashpoint-web\frontend\src\store\theme.ts

State

interface ThemeState {
  mode: ThemeMode; // 'light' | 'dark' | 'system'
  primaryColor: PrimaryColor; // 21 color options
  isLoading: boolean;

  setMode: (mode: ThemeMode) => void;
  setPrimaryColor: (color: PrimaryColor) => void;
  loadThemeFromServer: () => Promise<void>;
  syncThemeToServer: () => Promise<void>;
}

Primary Colors

21 color options available:

export type PrimaryColor =
  | 'blue' // Default
  | 'slate'
  | 'gray'
  | 'zinc'
  | 'neutral'
  | 'stone'
  | 'red'
  | 'orange'
  | 'amber'
  | 'yellow'
  | 'lime'
  | 'green'
  | 'emerald'
  | 'teal'
  | 'cyan'
  | 'sky'
  | 'indigo'
  | 'violet'
  | 'purple'
  | 'fuchsia'
  | 'pink'
  | 'rose';

Each color has light and dark mode variants stored as HSL values:

export const colorPalette: Record<
  PrimaryColor,
  { light: string; dark: string; label: string }
> = {
  blue: {
    light: '221.2 83.2% 53.3%',
    dark: '217.2 91.2% 59.8%',
    label: 'Blue',
  },
  red: { light: '0 72.2% 50.6%', dark: '0 72.2% 60.6%', label: 'Red' },
  // ... 19 more colors
};

Usage

import { useThemeStore } from '@/store/theme';

function ThemePicker() {
  const { mode, setMode } = useThemeStore();

  return (
    <select value={mode} onChange={(e) => setMode(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="system">System</option>
    </select>
  );
}

Actions

setMode

Changes theme mode and applies to document:

const { setMode } = useThemeStore();

setMode('dark'); // Immediately applies dark theme

Automatically:

  • Updates CSS classes on <html> element
  • Reapplies primary color for new theme
  • Syncs to server (if authenticated)

setPrimaryColor

Changes primary color and applies to CSS variables:

const { setPrimaryColor } = useThemeStore();

setPrimaryColor('emerald'); // Applies emerald color theme

Automatically:

  • Updates --primary and --ring CSS variables
  • Syncs to server (if authenticated)

loadThemeFromServer

Loads user's saved theme settings from server:

const { loadThemeFromServer } = useThemeStore();

await loadThemeFromServer();

Called automatically:

  • After successful login
  • On app rehydration (if authenticated)

syncThemeToServer

Saves current theme settings to server:

const { syncThemeToServer } = useThemeStore();

await syncThemeToServer();

Called automatically:

  • When mode changes
  • When primary color changes
  • Silent failure if not authenticated

Theme Application

Theme is applied by modifying CSS classes and custom properties:

const applyTheme = (mode: ThemeMode) => {
  const root = document.documentElement;

  // Remove existing theme classes
  root.classList.remove('light', 'dark');

  // Determine actual theme (resolve 'system')
  const actualTheme: 'light' | 'dark' = mode === 'system'
    ? window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
    : mode;

  // Apply theme class
  root.classList.add(actualTheme);
};

const applyPrimaryColor = (color: PrimaryColor, currentMode: ThemeMode) => {
  const root = document.documentElement;
  const actualTheme = /* determine actual theme */;

  const colorValue = colorPalette[color][actualTheme];

  // Apply CSS variables
  root.style.setProperty('--primary', colorValue);
  root.style.setProperty('--ring', colorValue);
};

System Theme Listener

Listens for system theme changes when in 'system' mode:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', () => {
    const state = useThemeStore.getState();
    if (state.mode === 'system') {
      applyTheme('system');
      applyPrimaryColor(state.primaryColor, 'system');
    }
  });

Server Data Validation

The theme store validates data received from the server before applying it:

// Validates mode against allowed values
if (!['light', 'dark', 'system'].includes(serverMode)) {
  // Reject invalid mode, keep current
}

// Validates primaryColor against known palette
if (!Object.keys(colorPalette).includes(serverColor)) {
  // Reject invalid color, keep current
}

This prevents crashes from malformed server responses or corrupted settings.

Persistence

Theme state is persisted to localStorage:

persist(
  (set, get) => ({
    /* ... */
  }),
  {
    name: 'flashpoint-theme-settings',
    partialize: (state) => ({
      mode: state.mode,
      primaryColor: state.primaryColor,
    }),
    onRehydrateStorage: () => (state) => {
      if (state) {
        // Apply theme after rehydration
        applyTheme(state.mode);
        applyPrimaryColor(state.primaryColor, state.mode);

        // Load from server if authenticated
        state.loadThemeFromServer().catch((error) => {
          console.debug('Failed to load theme from server:', error);
        });
      }
    },
  }
);

useUIStore

Manages UI state like sidebar visibility, view modes, and display preferences.

Location

D:\Repositories\Personal\flashpoint-web\frontend\src\store\ui.ts

State

interface UIState {
  sidebarOpen: boolean;
  sidebarCollapsed: boolean;
  viewMode: ViewMode; // 'grid' | 'list'
  selectedGameId: string | null;
  cardSize: CardSize; // 'small' | 'medium' | 'large'
  listColumns: ListColumns; // 1 | 2 | 3 | 4

  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  toggleSidebarCollapsed: () => void;
  setSidebarCollapsed: (collapsed: boolean) => void;
  setViewMode: (mode: ViewMode) => void;
  setSelectedGame: (id: string | null) => void;
  setCardSize: (size: CardSize) => void;
  setListColumns: (columns: ListColumns) => void;
}

Usage

import { useUIStore } from '@/store/ui';

function GameBrowseLayout() {
  const viewMode = useUIStore((state) => state.viewMode);
  const cardSize = useUIStore((state) => state.cardSize);
  const setViewMode = useUIStore((state) => state.setViewMode);

  return (
    <div>
      <button onClick={() => setViewMode('grid')}>Grid View</button>
      <button onClick={() => setViewMode('list')}>List View</button>

      {viewMode === 'grid' ? (
        <GameGrid cardSize={cardSize} />
      ) : (
        <GameList />
      )}
    </div>
  );
}

Actions

Sidebar Actions

const {
  sidebarOpen,
  toggleSidebar,
  setSidebarOpen,
  sidebarCollapsed,
  toggleSidebarCollapsed,
  setSidebarCollapsed,
} = useUIStore();

// Toggle sidebar open/closed (mobile)
toggleSidebar();

// Set specific state
setSidebarOpen(true);

// Toggle collapsed width (desktop)
toggleSidebarCollapsed();

View Mode Actions

const { viewMode, setViewMode } = useUIStore();

setViewMode('grid'); // Switch to grid view
setViewMode('list'); // Switch to list view

Card Size Actions

const { cardSize, setCardSize } = useUIStore();

setCardSize('small'); // Smaller cards
setCardSize('medium'); // Medium cards (default)
setCardSize('large'); // Larger cards

List Columns Actions

const { listColumns, setListColumns } = useUIStore();

setListColumns(1); // Single column
setListColumns(2); // Two columns
setListColumns(3); // Three columns
setListColumns(4); // Four columns

Default Values

{
  sidebarOpen: window.innerWidth >= 1024,  // Open on desktop, closed on mobile
  sidebarCollapsed: false,
  viewMode: 'grid',
  selectedGameId: null,
  cardSize: 'medium',
  listColumns: 1
}

Persistence

UI state is persisted to localStorage:

persist(
  (set) => ({
    /* ... */
  }),
  {
    name: 'flashpoint-ui-settings',
    partialize: (state) => ({
      sidebarOpen: state.sidebarOpen,
      sidebarCollapsed: state.sidebarCollapsed,
      cardSize: state.cardSize,
      viewMode: state.viewMode,
      listColumns: state.listColumns,
      // selectedGameId is intentionally not persisted
    }),
  }
);

Best Practices

1. Selective State Subscription

Only subscribe to the state you need:

// Good - only re-renders when mode changes
const mode = useThemeStore((state) => state.mode);

// Bad - re-renders on any theme store change
const themeStore = useThemeStore();

2. Access Store Outside Components

Use getState() for non-reactive access:

// In axios interceptor
const token = useAuthStore.getState().accessToken;

3. Batch Updates

Zustand automatically batches state updates:

setAuth(user, tokens); // Single update, single re-render

4. DevTools Integration

Enable Redux DevTools for debugging:

import { devtools } from 'zustand/middleware';

export const useAuthStore = create<AuthState>()(
  devtools(persist(/* ... */), { name: 'Auth Store' })
);

Further Reading