State Management
Honeycomb manages state using Svelte 5 runes and a consistent factory-function pattern across .svelte.ts files. There are no legacy writable/readable stores — everything uses $state and $derived.
The .svelte.ts store pattern
Section titled “The .svelte.ts store pattern”Every store in the codebase follows the same structure: a .svelte.ts file that declares $state runes at module scope (or inside a factory function) and returns a plain object with getters and setters.
src/lib/modules/honeycomb/ rooms/stores/room-state.svelte.ts direct-messages/stores/dm-state.svelte.ts presence/stores/presence-state.svelte.ts broadcast/stores/broadcast-state.svelte.ts calls/stores/call-state.svelte.tsAnatomy of a store
Section titled “Anatomy of a store”Every store follows this template:
import type { SomeType } from '../types/index.js';
let items = $state<SomeType[]>([]);let isLoading = $state(false);
export function getExampleState() { return { // Read-only getters -- reactive when used in components get items() { return items; }, get isLoading() { return isLoading; },
// Mutations setItems(newItems: SomeType[]) { items = newItems; }, addItem(item: SomeType) { items = [...items, item]; }, setLoading(loading: boolean) { isLoading = loading; },
// Cleanup reset() { items = []; isLoading = false; }, };}Key conventions:
- Module-level
$state— state variables are declared at the top of the file, outside the factory function. This makes them singletons shared across the app. - Getter-based reads — the returned object exposes state through
getaccessors, which preserves Svelte’s fine-grained reactivity tracking. - Immutable updates — arrays are replaced (
items = [...items, item]) rather than mutated (items.push(item)). Maps are cloned before modification. This ensures Svelte detects the change. reset()method — every store provides areset()to clear state on navigation or logout.
Two store flavors
Section titled “Two store flavors”Honeycomb uses two variations depending on whether the store needs external dependencies:
State lives at module scope. The factory is called once and the return value is shared.
let activeRoom = $state<Room | null>(null);let messages = $state<RoomMessage[]>([]);
export function getRoomState() { return { get activeRoom() { return activeRoom; }, get messages() { return messages; }, setActiveRoom(room: Room | null) { activeRoom = room; }, addMessage(msg: RoomMessage) { messages = [...messages, msg]; }, reset() { activeRoom = null; messages = []; }, };}The Supabase client or user ID is passed in. This is used for real-time stores that manage their own channel subscriptions.
export function createNotificationStore( supabase: SupabaseClient<Database>, userId: string,) { let unreadCount = $state(0); let channel: ReturnType<typeof supabase.channel> | null = null;
function init(initialCount: number) { unreadCount = initialCount; channel = supabase .channel(`notifications:${userId}`) .on('postgres_changes', { /* ... */ }, () => { unreadCount++; }) .subscribe(); }
function destroy() { if (channel) { supabase.removeChannel(channel); channel = null; } }
return { get unreadCount() { return unreadCount; }, init, destroy, };}Page data flow
Section titled “Page data flow”Data moves through three layers before reaching components:
hooks.server.ts -- creates locals.supabase, safeGetSession |+layout.server.ts -- fetches profile, notifications, installedApps |+layout.ts -- creates browser supabase client, exposes session |$page.data -- available in every componentServer layout ((app)/+layout.server.ts)
Section titled “Server layout ((app)/+layout.server.ts)”The authenticated layout fetches core data that every page in the app needs:
return { user, // Auth user object profile, // Full profile row (avatar, username, role, etc.) unreadNotifications, // Number for the notification badge installedApps, // Sidebar app list};Universal layout (+layout.ts)
Section titled “Universal layout (+layout.ts)”Adds the browser Supabase client and session:
return { supabase, session };Consuming in components
Section titled “Consuming in components”<script lang="ts"> import { page } from '$app/stores';
// These are always available inside (app) routes const { supabase, session, profile, unreadNotifications } = $page.data;</script>Key stores
Section titled “Key stores”Room state
Section titled “Room state”Tracks the currently open room, its messages, and members.
| Property | Type | Description |
|---|---|---|
activeRoom | Room | null | Currently viewed room |
messages | RoomMessage[] | Messages in the active room |
members | RoomMember[] | Room member list |
isLoadingMessages | boolean | Loading spinner flag |
import { getRoomState } from '$lib/modules/honeycomb/rooms/stores';const room = getRoomState();Direct messages state
Section titled “Direct messages state”Manages conversations and messages for 1-on-1 or group DMs.
| Property | Type | Description |
|---|---|---|
conversations | Conversation[] | All DM conversations |
activeConversation | Conversation | null | Currently open conversation |
messages | DirectMessage[] | Messages in the active conversation |
isLoadingMessages | boolean | Loading spinner flag |
import { getDmState } from '$lib/modules/honeycomb/direct-messages/stores';const dm = getDmState();Presence state
Section titled “Presence state”Tracks online users and typing indicators across rooms and conversations.
| Property | Type | Description |
|---|---|---|
onlineUsers | Map<string, PresenceUser> | User ID to presence data |
typingUsers | Map<string, TypingState> | Composite key to typing state |
Provides helper methods for querying typing state:
import { getPresenceState } from '$lib/modules/honeycomb/presence/stores';const presence = getPresenceState();
// Check if a specific user is onlinepresence.isOnline(userId);
// Get users currently typing in a roompresence.getTypingInRoom(roomId);Call state
Section titled “Call state”Manages active calls, participants, and local device state.
| Property | Type | Description |
|---|---|---|
activeCall | CallChannel | null | Current call |
participants | CallParticipant[] | Call participants |
isMuted | boolean | Local mic mute |
isCameraOff | boolean | Local camera off |
isScreenSharing | boolean | Local screen share active |
isConnecting | boolean | Connection in progress |
import { getCallState } from '$lib/modules/honeycomb/calls/stores';const call = getCallState();Broadcast state
Section titled “Broadcast state”Manages live broadcast sessions (hosting and viewing).
| Property | Type | Description |
|---|---|---|
activeBroadcast | Broadcast | null | Current broadcast |
viewers | BroadcastViewer[] | Current viewers |
broadcasts | Broadcast[] | Available broadcasts |
isHost | boolean | Whether the local user is hosting |
isConnecting | boolean | Connection in progress |
import { getBroadcastState } from '$lib/modules/honeycomb/broadcast/stores';const broadcast = getBroadcastState();Real-time state
Section titled “Real-time state”Stores that subscribe to Supabase Realtime channels follow an init / destroy lifecycle. The typical usage in a component:
<script lang="ts"> import { page } from '$app/stores'; import { onDestroy } from 'svelte'; import { createChatStore } from '$lib/modules/messaging/stores/chat-store.svelte';
const { supabase, session } = $page.data; const chat = createChatStore(supabase, chatId, session.user.id, authorsMap);
// Initialize with server-loaded data and start subscription chat.init(data.messages);
// Clean up the Realtime channel on unmount onDestroy(() => chat.destroy());</script>
{#each chat.messages as msg} <MessageBubble {msg} />{/each}Optimistic updates
Section titled “Optimistic updates”The chat store demonstrates the optimistic update pattern:
- Call
chat.addOptimistic(content)to immediately append a temporary message with acrypto.randomUUID()ID. - Send the actual insert to Supabase.
- When the Realtime
INSERTevent arrives, the store matches it against pending optimistic IDs and replaces the temporary message with the real one.
// In the componentfunction sendMessage(content: string) { chat.addOptimistic(content); supabase.from('messages').insert({ chat_id: chatId, user_id: session.user.id, content, });}This ensures the UI feels instant while staying consistent with the database state.
Map reactivity
Section titled “Map reactivity”The presence store uses Map objects for O(1) lookups. Since Svelte tracks reactivity by assignment, maps must be cloned before modification:
// Correct -- triggers reactivityonlineUsers = new Map(onlineUsers).set(user.user_id, user);
// Wrong -- mutates in place, Svelte does not detect the changeonlineUsers.set(user.user_id, user);This clone-on-write pattern applies to any non-primitive state value (Maps, Sets, objects used as dictionaries).