Orchestration of state across app shell components: sidebar, modals, theme, notifications, and user session.
State Overview
┌─────────────────────────────────────────────────────────────────────┐
│ Shell State │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Sidebar │ │ Theme │ │ Session │ │
│ │ │ │ │ │ │ │
│ │ • expanded │ │ • mode │ │ • user │ │
│ │ • pinned │ │ • accent │ │ • expiresAt │ │
│ │ • activeNav │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Modals │ │ Notifications│ │ Preferences │ │
│ │ │ │ │ │ │ │
│ │ • quickSearch│ │ • unreadCount│ │ • locale │ │
│ │ • aiAssistant│ │ • lastFetched│ │ • timezone │ │
│ │ • shortcuts │ │ │ │ • a11y │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
State Categories
| Category | Scope | Persistence | Source |
|---|---|---|---|
| Sidebar | Client | localStorage | User interaction |
| Theme | Client + Cookie | Cookie + DB | User preference |
| Session | Server → Client | Cookie | Better Auth |
| Modals | Client | None (ephemeral) | User interaction |
| Notifications | Server → Client | None | Polling/SSE |
| Preferences | Server → Client | DB | User settings |
Sidebar State
State Definition
SSR Safety: Never export state at module level. Module-level state is shared across all SSR requests in Node.js. Always use factory functions + context.
// src/lib/stores/sidebar.svelte.ts
import { browser } from '$app/environment';
import { getContext, setContext } from 'svelte';
interface SidebarState {
expanded: boolean; // Rail vs full sidebar (desktop)
pinned: boolean; // Stay expanded vs collapse on blur
mobileOpen: boolean; // Drawer open (mobile)
}
const STORAGE_KEY = 'sidebar-state';
const SIDEBAR_CTX = Symbol('sidebar');
export function createSidebarState() {
// Load from localStorage
const stored = browser ? localStorage.getItem(STORAGE_KEY) : null;
const initial: SidebarState = stored
? JSON.parse(stored)
: { expanded: false, pinned: false, mobileOpen: false };
let state = $state<SidebarState>(initial);
// Persist on change
$effect(() => {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
expanded: state.expanded,
pinned: state.pinned,
// Don't persist mobileOpen
}));
}
});
return {
get expanded() { return state.expanded; },
get pinned() { return state.pinned; },
get mobileOpen() { return state.mobileOpen; },
expand() { state.expanded = true; },
collapse() { if (!state.pinned) state.expanded = false; },
togglePin() { state.pinned = !state.pinned; },
openMobile() { state.mobileOpen = true; },
closeMobile() { state.mobileOpen = false; },
toggleMobile() { state.mobileOpen = !state.mobileOpen; },
};
}
// Context helpers for SSR-safe access
export function setSidebarContext() {
const sidebar = createSidebarState();
setContext(SIDEBAR_CTX, sidebar);
return sidebar;
}
export function getSidebar() {
return getContext<ReturnType<typeof createSidebarState>>(SIDEBAR_CTX);
}
Integration with Breakpoints
<script lang="ts">
import { getSidebar } from '$lib/stores/sidebar.svelte';
import { MediaQuery } from 'svelte/reactivity';
const sidebar = getSidebar(); // Get from context (SSR-safe)
const isDesktop = new MediaQuery('(min-width: 1024px)');
// Auto-close mobile drawer on resize to desktop
$effect(() => {
if (isDesktop.matches && sidebar.mobileOpen) {
sidebar.closeMobile();
}
});
</script>
Theme State
State Definition
SSR Safety: Theme uses context pattern to avoid module-level state sharing across requests.
// src/lib/stores/theme.svelte.ts
import { browser } from '$app/environment';
import { getContext, setContext } from 'svelte';
type ThemeMode = 'light' | 'dark' | 'system';
type AccentColor = 'blue' | 'purple' | 'green' | 'orange';
interface ThemeState {
mode: ThemeMode;
accent: AccentColor;
resolvedMode: 'light' | 'dark'; // Computed from mode + system preference
}
const THEME_CTX = Symbol('theme');
export function createThemeState(initial: { mode: ThemeMode; accent: AccentColor }) {
let state = $state<ThemeState>({
mode: initial.mode,
accent: initial.accent,
resolvedMode: 'light',
});
// Resolve system preference
$effect(() => {
if (!browser) return;
if (state.mode === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
state.resolvedMode = mediaQuery.matches ? 'dark' : 'light';
const handler = (e: MediaQueryListEvent) => {
state.resolvedMode = e.matches ? 'dark' : 'light';
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
state.resolvedMode = state.mode;
}
});
// Apply to DOM
$effect(() => {
if (!browser) return;
document.documentElement.classList.toggle('dark', state.resolvedMode === 'dark');
document.documentElement.dataset.accent = state.accent;
});
return {
get mode() { return state.mode; },
get accent() { return state.accent; },
get resolvedMode() { return state.resolvedMode; },
get isDark() { return state.resolvedMode === 'dark'; },
setMode(mode: ThemeMode) {
state.mode = mode;
// Persist to cookie (for SSR)
document.cookie = `theme=${mode};path=/;max-age=31536000;SameSite=Lax`;
// Persist to DB (async)
fetch('/api/user/preferences', {
method: 'PATCH',
body: JSON.stringify({ theme: mode }),
});
},
setAccent(accent: AccentColor) {
state.accent = accent;
fetch('/api/user/preferences', {
method: 'PATCH',
body: JSON.stringify({ accentColor: accent }),
});
},
};
}
// Context helpers for SSR-safe access
export function setThemeContext(initial: { mode: ThemeMode; accent: AccentColor }) {
const theme = createThemeState(initial);
setContext(THEME_CTX, theme);
return theme;
}
export function getTheme() {
return getContext<ReturnType<typeof createThemeState>>(THEME_CTX);
}
SSR Hydration (No Flash)
<!-- app.html - Inline script runs before body renders -->
<script>
(function() {
const theme = document.cookie.match(/theme=(\w+)/)?.[1] ?? 'system';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (theme === 'system' && prefersDark);
if (isDark) document.documentElement.classList.add('dark');
})();
</script>
Modal State
Mutual Exclusion
Only one modal can be open at a time. Opening one closes others.
SSR Safety: Modals are client-only state but still use context pattern for consistency and testability.
// src/lib/stores/modals.svelte.ts
import { getContext, setContext } from 'svelte';
type ModalId = 'quickSearch' | 'aiAssistant' | 'shortcuts' | 'sessionExpiry' | null;
const MODALS_CTX = Symbol('modals');
export function createModalState() {
let activeModal = $state<ModalId>(null);
let modalData = $state<Record<string, unknown>>({});
return {
get active() { return activeModal; },
isOpen(id: ModalId) {
return activeModal === id;
},
open(id: ModalId, data?: Record<string, unknown>) {
activeModal = id;
if (data) modalData = data;
},
close() {
activeModal = null;
modalData = {};
},
getData<T>(key: string): T | undefined {
return modalData[key] as T;
},
};
}
// Context helpers for SSR-safe access
export function setModalsContext() {
const modals = createModalState();
setContext(MODALS_CTX, modals);
return modals;
}
export function getModals() {
return getContext<ReturnType<typeof createModalState>>(MODALS_CTX);
}
Focus Restoration
<script lang="ts">
import { getModals } from '$lib/stores/modals.svelte';
const modals = getModals(); // Get from context (SSR-safe)
let triggerRef: HTMLButtonElement;
let previousFocus: HTMLElement | null = null;
function openQuickSearch() {
previousFocus = document.activeElement as HTMLElement;
modals.open('quickSearch');
}
$effect(() => {
if (!modals.isOpen('quickSearch') && previousFocus) {
// Restore focus when modal closes
previousFocus.focus();
previousFocus = null;
}
});
</script>
<button bind:this={triggerRef} onclick={openQuickSearch}>
Search
</button>
Notification Badge State
Polling Pattern
SSR Safety: Notifications use context pattern to avoid module-level state sharing.
// src/lib/stores/notifications.svelte.ts
import { browser } from '$app/environment';
import { getContext, setContext } from 'svelte';
const NOTIFICATIONS_CTX = Symbol('notifications');
export function createNotificationState(initialCount: number) {
let unreadCount = $state(initialCount);
let lastFetched = $state(Date.now());
// Poll every 30 seconds
$effect(() => {
if (!browser) return;
const interval = setInterval(async () => {
try {
const res = await fetch('/api/notifications/unread-count');
const { count } = await res.json();
unreadCount = count;
lastFetched = Date.now();
} catch (error) {
console.error('Failed to fetch notification count');
}
}, 30_000);
return () => clearInterval(interval);
});
return {
get count() { return unreadCount; },
get lastFetched() { return lastFetched; },
// Optimistic update when marking as read
decrement() {
unreadCount = Math.max(0, unreadCount - 1);
},
// Force refresh
async refresh() {
const res = await fetch('/api/notifications/unread-count');
const { count } = await res.json();
unreadCount = count;
lastFetched = Date.now();
},
};
}
// Context helpers for SSR-safe access
export function setNotificationsContext(initialCount: number) {
const notifications = createNotificationState(initialCount);
setContext(NOTIFICATIONS_CTX, notifications);
return notifications;
}
export function getNotifications() {
return getContext<ReturnType<typeof createNotificationState>>(NOTIFICATIONS_CTX);
}
Session State
Session is server-authoritative. Client receives session data from load functions.
// src/routes/(app)/+layout.server.ts
export const load = async ({ locals }) => {
return {
user: locals.user,
session: locals.session,
};
};
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
let { data, children } = $props();
// Make session available to all child components
setContext('user', () => data.user);
setContext('session', () => data.session);
</script>
Important: Never store session in module-level state. Always use event.locals on server and context/props on client.
State Initialization Order
Shell initialization happens in a specific order to prevent flashes and ensure dependencies:
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { setSidebarContext } from '$lib/stores/sidebar.svelte';
import { setThemeContext } from '$lib/stores/theme.svelte';
import { setModalsContext } from '$lib/stores/modals.svelte';
import { setNotificationsContext } from '$lib/stores/notifications.svelte';
import { initKeyboardHandler } from '$lib/shortcuts';
let { data, children } = $props();
// Initialize all contexts (SSR-safe, request-scoped)
// 1. Theme (already applied in app.html, just sync state)
const theme = setThemeContext({
mode: data.settings?.theme ?? 'system',
accent: data.settings?.accentColor ?? 'blue',
});
// 2. Sidebar (loads from localStorage on client)
const sidebar = setSidebarContext();
// 3. Modals (ephemeral client state)
const modals = setModalsContext();
// 4. Notifications (start polling)
const notifications = setNotificationsContext(data.unreadCount ?? 0);
// 5. Keyboard shortcuts (register handlers)
onMount(() => {
return initKeyboardHandler();
});
</script>
{@render children()}
Cross-Component Communication
Event-Based Updates
When one component needs to trigger updates in another:
// Option 1: Svelte 5 reactive state (preferred)
// Components import and react to shared state
// Option 2: Custom events for decoupled components
import { createEventDispatcher } from 'svelte';
// In notification card
function markAsRead() {
// Update local state
notification.read = true;
// Notify sidebar badge
window.dispatchEvent(new CustomEvent('notification:read'));
}
// In sidebar
const notifications = getNotifications(); // Get from context
$effect(() => {
if (!browser) return;
const handler = () => notifications.decrement();
window.addEventListener('notification:read', handler);
return () => window.removeEventListener('notification:read', handler);
});
Data Flow Diagram
Server (load functions)
│
▼
┌─────────────────────────────────────┐
│ +layout.svelte (root) │
│ • Receives: user, session, │
│ settings, unreadCount │
│ • Initializes: theme, notifications│
│ • Provides: context │
└─────────────────────────────────────┘
│
├───────────────┬──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Sidebar │ │ Content │ │ Modals │
│ │ │ │ │ │
│ Reads: │ │ Reads: │ │ Reads: │
│ • user │ │ • page │ │ • modals│
│ • notif │ │ data │ │ • theme │
│ • theme │ │ │ │ │
└─────────┘ └─────────┘ └─────────┘
│
│ (user action)
▼
localStorage / API
Debugging State
Add a debug panel in development:
<!-- src/lib/components/dev/StateDebugger.svelte -->
<script lang="ts">
import { dev } from '$app/environment';
import { getSidebar } from '$lib/stores/sidebar.svelte';
import { getTheme } from '$lib/stores/theme.svelte';
import { getModals } from '$lib/stores/modals.svelte';
import { getNotifications } from '$lib/stores/notifications.svelte';
// Get all state from context (SSR-safe)
const sidebar = getSidebar();
const theme = getTheme();
const modals = getModals();
const notifications = getNotifications();
let expanded = $state(false);
</script>
{#if dev}
<div class="fixed bottom-4 right-4 z-debug">
<button onclick={() => expanded = !expanded} class="btn btn-sm">
State
</button>
{#if expanded}
<div class="bg-surface border rounded-lg p-4 mt-2 text-xs font-mono">
<pre>{JSON.stringify({
sidebar: {
expanded: sidebar.expanded,
pinned: sidebar.pinned,
mobileOpen: sidebar.mobileOpen,
},
theme: {
mode: theme.mode,
resolved: theme.resolvedMode,
},
modals: {
active: modals.active,
},
notifications: {
count: notifications.count,
},
}, null, 2)}</pre>
</div>
{/if}
</div>
{/if}
Component Location
src/lib/stores/
├── sidebar.svelte.ts # Sidebar expanded/pinned/mobile state
├── theme.svelte.ts # Theme mode and accent
├── modals.svelte.ts # Active modal tracking
├── notifications.svelte.ts # Unread count, polling
└── index.ts # Exports
Related
- ./sidebar.md - Sidebar component behavior
- ./settings.md - Settings & Preferences
- ./session-lifecycle.md - Session state
- ../state.md - Svelte 5 state patterns