Skip to content

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.

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

Every store follows this template:

example-state.svelte.ts
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 get accessors, 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 a reset() to clear state on navigation or logout.

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.

room-state.svelte.ts
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 = []; },
};
}

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 component

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
};

Adds the browser Supabase client and session:

return { supabase, session };
<script lang="ts">
import { page } from '$app/stores';
// These are always available inside (app) routes
const { supabase, session, profile, unreadNotifications } = $page.data;
</script>

Tracks the currently open room, its messages, and members.

PropertyTypeDescription
activeRoomRoom | nullCurrently viewed room
messagesRoomMessage[]Messages in the active room
membersRoomMember[]Room member list
isLoadingMessagesbooleanLoading spinner flag
import { getRoomState } from '$lib/modules/honeycomb/rooms/stores';
const room = getRoomState();

Manages conversations and messages for 1-on-1 or group DMs.

PropertyTypeDescription
conversationsConversation[]All DM conversations
activeConversationConversation | nullCurrently open conversation
messagesDirectMessage[]Messages in the active conversation
isLoadingMessagesbooleanLoading spinner flag
import { getDmState } from '$lib/modules/honeycomb/direct-messages/stores';
const dm = getDmState();

Tracks online users and typing indicators across rooms and conversations.

PropertyTypeDescription
onlineUsersMap<string, PresenceUser>User ID to presence data
typingUsersMap<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 online
presence.isOnline(userId);
// Get users currently typing in a room
presence.getTypingInRoom(roomId);

Manages active calls, participants, and local device state.

PropertyTypeDescription
activeCallCallChannel | nullCurrent call
participantsCallParticipant[]Call participants
isMutedbooleanLocal mic mute
isCameraOffbooleanLocal camera off
isScreenSharingbooleanLocal screen share active
isConnectingbooleanConnection in progress
import { getCallState } from '$lib/modules/honeycomb/calls/stores';
const call = getCallState();

Manages live broadcast sessions (hosting and viewing).

PropertyTypeDescription
activeBroadcastBroadcast | nullCurrent broadcast
viewersBroadcastViewer[]Current viewers
broadcastsBroadcast[]Available broadcasts
isHostbooleanWhether the local user is hosting
isConnectingbooleanConnection in progress
import { getBroadcastState } from '$lib/modules/honeycomb/broadcast/stores';
const broadcast = getBroadcastState();

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}

The chat store demonstrates the optimistic update pattern:

  1. Call chat.addOptimistic(content) to immediately append a temporary message with a crypto.randomUUID() ID.
  2. Send the actual insert to Supabase.
  3. When the Realtime INSERT event arrives, the store matches it against pending optimistic IDs and replaces the temporary message with the real one.
// In the component
function 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.

The presence store uses Map objects for O(1) lookups. Since Svelte tracks reactivity by assignment, maps must be cloned before modification:

// Correct -- triggers reactivity
onlineUsers = new Map(onlineUsers).set(user.user_id, user);
// Wrong -- mutates in place, Svelte does not detect the change
onlineUsers.set(user.user_id, user);

This clone-on-write pattern applies to any non-primitive state value (Maps, Sets, objects used as dictionaries).