State Management
ryOS uses Zustand for state management with 40 stores following consistent patterns, combined with localStorage and IndexedDB for persistence. Cloud sync (useCloudSyncStore + useAutoCloudSync + the v2 engine in src/sync/) extends persistence to a remote backend: store state is decomposed into per-key documents, deltas travel as journal ops through /api/sync/v2/*, and small remote changes apply in real time straight from Pusher events (see docs/proposals/cloud-sync-v2.md). CPU-intensive search indexing (Spotlight dynamic results) is offloaded to a dedicated Web Worker rather than running on the main thread.
Architecture Overview
flowchart TB
subgraph "Presentation Layer"
COMP[React Components]
end
subgraph "Store Layer"
subgraph "Core Stores"
APP[useAppStore
Window Management]
FILES[useFilesStore
File System]
THEME[useThemeStore
Theming]
end
subgraph "Feature Stores"
CHAT[useChatsStore]
IPOD[useIpodStore]
DOCK[useDockStore]
end
subgraph "Settings Stores"
AUDIO[useAudioSettingsStore]
DISPLAY[useDisplaySettingsStore]
end
end
subgraph "Persistence Layer"
LS[(localStorage)]
IDB[(IndexedDB)]
end
subgraph "External"
API[REST APIs]
REDIS[(Redis Cache)]
end
COMP --> APP
COMP --> FILES
COMP --> CHAT
COMP --> IPOD
APP -.->|cross-store| FILES
IPOD -.->|auth| CHAT
APP --> LS
FILES --> IDB
CHAT --> IDB
IPOD --> IDB
DISPLAY --> IDB
IPOD --> API
CHAT --> API
API --> REDIS
Store Inventory
Core System Stores
| Store | Persistence Key | Purpose |
|---|---|---|
useAppStore | ryos:app-store | Instance-based window management, boot state, AI model selection, recent apps/documents, expose mode |
useFilesStore | ryos:files (IndexedDB) | Virtual filesystem metadata |
useThemeStore | ryos:theme (manual) | OS theme selection + legacy Windows CSS loading |
useDisplaySettingsStore | ryos:display-settings | Wallpaper, shaders, screen saver |
useDockStore | ryos:dock | Dock pinned items, scale, visibility |
useAudioSettingsStore | ryos:audio-settings | Volume controls, audio feature toggles |
useLanguageStore | ryos:language + ryos:language-initialized (manual) | UI language preference and first-run detection |
useSpotlightStore | None (ephemeral UI state) | Spotlight open/query/selection UI state |
useCloudSyncStore | ryos:cloud-sync | Cloud sync preferences, per-category status, deletion markers |
useUndoRedoStore | None (ephemeral) | Per-instance undo/redo handler registry for foreground dispatch |
App-Specific Stores
| Store | Persistence Key | Purpose |
|---|---|---|
useChatsStore | ryos:chats (IndexedDB) | UI/auth metadata in persisted_state; AI messages and room caches in dedicated entity stores |
useIpodStore | ryos:ipod (IndexedDB) | Music library, playback state, lyrics |
useVideoStore | ryos:videos | Video library, playback |
useKaraokeStore | ryos:karaoke | Karaoke session state |
useTerminalStore | ryos:terminal | Command history, vim state |
useSynthStore | ryos:synth | Synthesizer presets |
useSoundboardStore | ryos:soundboard (IndexedDB) | Board metadata in persisted_state; recordings stored as Blob rows in soundboard_audio |
useAppletStore | ryos:applet | Applet window dimensions |
useFinderStore | ryos:finder | Finder instances, view preferences |
useTextEditStore | ryos:textedit (IndexedDB) | Scalar metadata in persisted_state; open instances and drafts in textedit_instances |
useInternetExplorerStore | ryos:internet-explorer | Favorites, history |
usePaintStore | ryos:paint | Tool settings |
usePhotoBoothStore | ryos:photobooth | Recent photos |
usePcStore | ryos:pc | Virtual PC state |
useInfinitePcStore | ryos:infinite-pc | v86 OS-browser presets and runtime state |
useStickiesStore | ryos:stickies (IndexedDB) | Sticky notes and note layout |
useCalendarStore | ryos:calendar (IndexedDB) | Calendar events, todo items, groups |
useContactsStore | ryos:contacts (IndexedDB) | Address book contacts, vCard import/export |
useDashboardStore | ryos:dashboard | Dashboard widget layout and configuration |
useListenSessionStore | None (ephemeral) | Listen Together session state and Pusher sync |
useInfiniteMacStore | ryos:infinite-mac | Infinite Mac runtime preferences |
useBooksStore | ryos:books | EPUB book library, reading position, last-opened book |
useMapsStore | ryos:maps:v1 | Maps search results, selected place, map region |
useTvStore | ryos:tv | TV channels and playback state |
useWeatherStore | ryos:weather | Weather data and saved location |
useTimezoneStore | ryos:timezone | World clock timezone selection |
useMediaLibraryStore | (alias of useIpodStore) | Shared music-library/lyrics state for iPod, Karaoke, and Winamp |
useAirDropStore | None (ephemeral) | AirDrop transfer session and channel subscription |
useWeatherSimulationStore | None (ephemeral) | Desktop weather-effect simulation state |
useAdminDashboardStore | None (ephemeral) | Admin dashboard UI/query state |
Store Architecture Diagram
graph TD
subgraph Core["Core System"]
AppStore[useAppStore
Windows & Boot]
ThemeStore[useThemeStore
Theme]
DisplayStore[useDisplaySettingsStore
Wallpaper & Shaders]
AudioStore[useAudioSettingsStore
Volume & TTS]
DockStore[useDockStore
Pinned Items]
CloudStore[useCloudSyncStore
Cloud Sync]
UndoStore[useUndoRedoStore
Undo/Redo]
end
subgraph Media["Media Apps"]
IpodStore[useIpodStore
Music Library]
VideoStore[useVideoStore
Video Library]
SynthStore[useSynthStore
Synth Presets]
end
subgraph Social["Communication"]
ChatsStore[useChatsStore
Chat & Auth]
end
subgraph Productivity["Productivity"]
FilesStore[useFilesStore
Virtual FS]
TerminalStore[useTerminalStore
CLI State]
CalendarStore[useCalendarStore
Events & Todos]
ContactsStore[useContactsStore
Contacts]
end
AppStore --> |manages| Media
AppStore --> |manages| Social
AppStore --> |manages| Productivity
ChatsStore -.-> |auth| IpodStore
CloudStore -.-> |sync| FilesStore
CloudStore -.-> |sync| IpodStore
Store Pattern
All stores follow a consistent Zustand pattern with persist middleware:
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface ExampleState {
// State
someValue: string;
items: Item[];
// Actions
setSomeValue: (v: string) => void;
addItem: (item: Item) => void;
removeItem: (id: string) => void;
// Computed (via get())
getItemById: (id: string) => Item | undefined;
}
export const useExampleStore = create<ExampleState>()(
persist(
(set, get) => ({
// Initial state
someValue: "default",
items: [],
// Actions
setSomeValue: (v) => set({ someValue: v }),
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) => set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
// Computed getter
getItemById: (id) => get().items.find((item) => item.id === id),
}),
{
name: "ryos:example", // localStorage key
version: 1, // Migration version
storage: createJSONStorage(() => localStorage),
// Selective persistence
partialize: (state) => ({
someValue: state.someValue,
items: state.items,
}),
// Version migrations
migrate: (persisted, version) => {
if (version < 1) {
// Handle migration
}
return persisted;
},
// Post-rehydration hook
onRehydrateStorage: () => (state, error) => {
if (state) {
// Initialize or sync after rehydration
}
},
}
)
);
Persistence Strategies
localStorage Persistence (via Zustand persist)
Used for settings, preferences, and lightweight metadata:
persist(
(set) => ({ /* state and actions */ }),
{
name: "ryos:audio-settings",
version: STORE_VERSION,
partialize: (state) => ({
masterVolume: state.masterVolume,
uiVolume: state.uiVolume,
// Only persist necessary fields
}),
}
)
IndexedDB Integration
Used for large binary data and quota-sensitive Zustand slices:
// IndexedDB stores
const STORES = {
DOCUMENTS: "documents", // Text files
IMAGES: "images", // Binary images
BOOKS: "books", // EPUB book files
BOOK_THUMBNAILS: "book_thumbnails", // Generated book cover thumbnails
TRASH: "trash", // Deleted file content
CUSTOM_WALLPAPERS: "custom_wallpapers",
APPLETS: "applets", // HTML applet content
APPLE_MUSIC_LIBRARY: "apple_music_library",
APPLE_MUSIC_PLAYLISTS: "apple_music_playlists",
APPLE_MUSIC_PLAYLIST_TRACKS: "apple_music_playlist_tracks",
SYNC2_STATE: "sync2_state", // Per-user cursor, shadow, and dirty state
SOUNDBOARD_AUDIO: "soundboard_audio",
CHATS_AI_MESSAGES: "chats_ai_messages",
CHATS_ROOM_MESSAGES: "chats_room_messages",
TEXTEDIT_INSTANCES: "textedit_instances",
VFS_ITEMS: "vfs_items",
PERSISTED_STATE: "persisted_state", // Scalar metadata / unsplit slices
};
// Database: ryOS (version 15)
interface StoredContent {
name: string; // Original filename
content: string | Blob; // File content
}
Quota-sensitive snapshots use createIndexedDBPersistStorage(). Entity-heavy stores use createSplitIndexedDBPersistStorage(), which keeps a small metadata record in persisted_state, migrates monolithic snapshots on hydration, and writes changed entities into dedicated object stores. Both adapters debounce and serialize writes and participate in backup flushing. Because IndexedDB hydration is asynchronous, default seeding and sync jobs wait for persist.hasHydrated() (or the shared usePersistHydrated hook).
Cloud Sync v2 keeps its per-user cursor and shadow in the dedicated sync2_state store. This operational metadata migrates from legacy ryos:sync2:state:* localStorage keys and is intentionally excluded from manual backups.
Hybrid Persistence (Files Store)
The useFilesStore demonstrates a sophisticated two-layer architecture:
flowchart TB
subgraph "Metadata Layer"
META[IndexedDB persisted_state
libraryState]
FS[IndexedDB vfs_items
path, name, type, uuid, status]
end
subgraph "Content Layer (IndexedDB)"
DOC[documents store]
IMG[images store]
APP[applets store]
end
subgraph "Lazy Loading"
CACHE[In-memory cache]
PENDING[Pending lazy load map]
end
META -.->|lifecycle state| FS
FS -->|uuid reference| DOC
FS -->|uuid reference| IMG
FS -->|uuid reference| APP
PENDING -->|on-demand fetch| DOC
PENDING -->|on-demand fetch| IMG
CACHE -.->|cached JSON| FS
Manual localStorage (Theme Store)
Some stores use manual persistence for more control:
setTheme: (theme) => {
set({ current: theme });
localStorage.setItem("ryos:theme", theme);
document.documentElement.dataset.osTheme = theme;
ensureLegacyCss(theme);
},
hydrate: () => {
const saved = localStorage.getItem("ryos:theme");
const theme = saved || "macosx";
set({ current: theme });
document.documentElement.dataset.osTheme = theme;
ensureLegacyCss(theme);
},
Migration System
Version-Based Migrations
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as StoreState;
if (version < 5) {
// Add status field and UUIDs
for (const path in state.items) {
state.items[path] = {
...state.items[path],
status: state.items[path].status || "active",
uuid: !state.items[path].isDirectory
? state.items[path].uuid || uuidv4()
: undefined,
};
}
}
if (version < 6) {
// Add timestamp fields
const now = Date.now();
for (const path in state.items) {
state.items[path].createdAt = state.items[path].createdAt || now;
state.items[path].modifiedAt = state.items[path].modifiedAt || now;
}
}
return state;
},
Recovery Mechanisms
Username recovery uses a plain-text localStorage key to survive store resets. Auth tokens are not stored in localStorage — they live exclusively in an httpOnly cookie (ryos_auth) set by the server. On page reload the client calls GET /api/auth/session (the cookie is sent automatically via credentials: "include") to verify the session.
const USERNAME_RECOVERY_KEY = "ryos:auth:username-recovery";
// Recovery on rehydrate
onRehydrateStorage: () => (state, error) => {
if (state?.username === null) {
const recovered = getUsernameFromRecovery();
if (recovered) state.username = recovered;
}
// Auth token is restored from httpOnly cookie, not localStorage
restoreSessionFromCookie(state.username);
}
Cross-Store Communication
Direct Store Access
Stores can access other stores via getState():
// In useIpodStore - accessing ChatsStore for auth
async function saveLyricOffsetToServer(trackId: string, offset: number) {
const { username, authToken } = useChatsStore.getState();
if (!username) {
console.log("Skipping save - user not logged in");
return false;
}
// Make authenticated API call.
// Authorization header is sent when an in-memory token is available;
// otherwise the httpOnly cookie provides auth automatically.
const headers: Record<string, string> = {};
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
headers["X-Username"] = username;
}
await fetch("/api/songs/...", {
credentials: "include",
headers,
});
}
Store Dependency Pattern
// In useAppStore - accessing AppletStore for window sizing
createAppInstance: (appId, initialData, title) => {
if (appId === "applet-viewer") {
const path = (initialData as { path?: string })?.path;
if (path) {
const saved = useAppletStore.getState().getAppletWindowSize(path);
if (saved) size = saved;
}
}
}
Event-Based Communication
ryOS now uses typed app-event primitives for most cross-feature interactions:
// Typed launch/update/focus events
requestAppLaunch({ appId: "finder", initialPath: "/Documents" });
emitAppUpdate({ appId: "textedit", instanceId, initialData });
requestWindowFocus({ instanceId });
// Typed global UI toggles
toggleSpotlightSearch();
toggleExposeView();
selectExposeWindow({ instanceId });
// Typed document/applet update events
emitDocumentUpdated({ path, content });
emitAppletUpdated({ path, content });
Legacy window.dispatchEvent(new CustomEvent(...)) broadcasts still exist for a few low-level flows (for example instanceStateChange, wallpaperChange, and close-request events), but new app-level integrations should use src/utils/appEventBus.ts.
Cross-Store Dependencies Map
flowchart LR
subgraph Components["React Components"]
C1[IpodApp]
C2[KaraokeApp]
C3[TextEditApp]
end
subgraph Stores["Zustand Stores"]
S1[useIpodStore]
S2[useKaraokeStore]
S3[useTextEditStore]
S4[useChatsStore]
S5[useAppStore]
end
subgraph Persist["localStorage"]
P3[(ryos:app-store)]
end
subgraph LargePersist["IndexedDB persisted_state"]
P2[(ryos:chats)]
P1[(ryos:ipod)]
P4[(ryos:textedit)]
end
C1 <--> S1
C2 <--> S2
C3 <--> S3
S2 -.-> |music library| S1
S3 -.-> |foreground instance| S5
S1 -.-> |auth credentials| S4
S1 --> P1
S3 --> P4
S4 --> P2
S5 --> P3
State Flow Patterns
App Instance Lifecycle
stateDiagram-v2
[*] --> Creating: launchApp()
Creating --> Loading: createAppInstance()
Loading --> Foreground: markInstanceAsLoaded()
Foreground --> Background: bringInstanceToForeground(other)
Background --> Foreground: bringInstanceToForeground(this)
Foreground --> Minimized: minimizeInstance()
Background --> Minimized: minimizeInstance()
Minimized --> Foreground: restoreInstance()
Foreground --> [*]: closeAppInstance()
Background --> [*]: closeAppInstance()
Minimized --> [*]: closeAppInstance()
Library Initialization Flow
sequenceDiagram
participant R as Rehydrate
participant S as Store
participant LS as localStorage
participant IDB as IndexedDB
participant API as External API
R->>IDB: Load quota-sensitive persisted state
IDB-->>R: Return state (or migrate legacy localStorage)
R->>S: Set initial state
alt libraryState === "uninitialized"
S->>API: loadDefaultFiles()
API-->>S: JSON data
S->>LS: Persist metadata
S->>IDB: Save text content
S->>S: registerFilesForLazyLoad(assets)
else libraryState === "loaded"
S->>S: syncRootDirectories()
S->>S: ensureDefaultDesktopShortcuts()
end
Spotlight Search Indexing (Worker Offload)
useSpotlightSearch builds snapshots from useFilesStore, useIpodStore, useInternetExplorerStore, useVideoStore, useCalendarStore, and useContactsStore, then posts indexing/query requests to spotlightSearch.worker.ts. Initial indexing is deferred with requestIdleCallback (fallback timeout), which keeps first-render interactions responsive while search data is prepared in the background.
Best Practices
Selective Persistence (partialize)
partialize: (state) => ({
// Only persist necessary state, exclude:
// - Transient UI state (isLoading, etc.)
// - Large computed data
// - Runtime-only values
tracks: state.tracks,
currentSongId: state.currentSongId,
// NOT: isPlaying, currentLyrics, currentFuriganaMap
}),
Optimized Selectors (useShallow)
import { useShallow } from "zustand/react/shallow";
// Prevents unnecessary re-renders when selecting multiple values
function useAppStoreShallow<T>(
selector: (state: AppStoreState) => T
): T {
return useAppStore(useShallow(selector));
}
// Usage
const { instances, foregroundInstanceId } = useAppStoreShallow((s) => ({
instances: s.instances,
foregroundInstanceId: s.foregroundInstanceId,
}));
Debounced Server Sync
const lyricOffsetSaveTimers = new Map<string, NodeJS.Timeout>();
function debouncedSaveLyricOffset(trackId: string, offset: number): void {
const existing = lyricOffsetSaveTimers.get(trackId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
lyricOffsetSaveTimers.delete(trackId);
saveLyricOffsetToServer(trackId, offset);
}, 2000); // 2 second debounce
lyricOffsetSaveTimers.set(trackId, timer);
}
Data Caching with Deduplication
let cachedFileSystemData: FileSystemData | null = null;
let fileSystemDataPromise: Promise<FileSystemData> | null = null;
async function loadDefaultFiles(): Promise<FileSystemData> {
if (cachedFileSystemData) return cachedFileSystemData;
if (fileSystemDataPromise) return fileSystemDataPromise; // Dedup in-flight
fileSystemDataPromise = (async () => {
const data = await fetch("/data/filesystem.json").then(r => r.json());
cachedFileSystemData = data;
return data;
})();
return fileSystemDataPromise;
}
Immutable Updates
// Array updates without mutation
set((state) => ({
tracks: state.tracks.map((track, i) =>
i === trackIndex ? { ...track, lyricOffset: newOffset } : track
),
}));
// Object updates without mutation
set((state) => ({
instances: {
...state.instances,
[instanceId]: { ...state.instances[instanceId], isForeground: true },
},
}));
Related Documentation
- Window Management - Window state in useAppStore
- File System - useFilesStore details
- Audio System - useAudioSettingsStore details